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,11 +1,42 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "mail"
4
- require "mail-iso-2022-jp"
5
2
  require "net/imap"
6
3
  require "time"
4
+ require "tempfile"
7
5
  require "fileutils"
8
6
  require "timeout"
7
+ require "digest"
8
+ require "nkf"
9
+ require "groonga"
10
+ require 'google/api_client/client_secrets'
11
+ require 'google/api_client/auth/storage'
12
+ require 'google/api_client/auth/storages/file_store'
13
+ require 'launchy'
14
+
15
+ class Net::SMTP
16
+ def auth_xoauth2(user, secret)
17
+ check_auth_args user, secret
18
+ res = critical {
19
+ s = Mournmail.xoauth2_string(user, secret)
20
+ get_response('AUTH XOAUTH2 ' + base64_encode(s))
21
+ }
22
+ check_auth_response res
23
+ res
24
+ end
25
+ end
26
+
27
+ class Net::IMAP::Xoauth2Authenticator
28
+ def process(data)
29
+ Mournmail.xoauth2_string(@user, @access_token)
30
+ end
31
+
32
+ private
33
+
34
+ def initialize(user, access_token)
35
+ @user = user
36
+ @access_token = access_token
37
+ end
38
+ end
39
+ Net::IMAP.add_authenticator("XOAUTH2", Net::IMAP::Xoauth2Authenticator)
9
40
 
10
41
  module Mournmail
11
42
  begin
@@ -15,31 +46,84 @@ module Mournmail
15
46
  HAVE_MAIL_GPG = false
16
47
  end
17
48
 
18
- def self.define_variable(name, value = nil)
49
+ def self.define_variable(name, initial_value: nil, attr: nil)
19
50
  var_name = "@" + name.to_s
20
51
  if !instance_variable_defined?(var_name)
21
- instance_variable_set(var_name, value)
52
+ instance_variable_set(var_name, initial_value)
53
+ end
54
+ case attr
55
+ when :accessor
56
+ singleton_class.send(:attr_accessor, name)
57
+ when :reader
58
+ singleton_class.send(:attr_reader, name)
59
+ when :writer
60
+ singleton_class.send(:attr_writer, name)
22
61
  end
23
- singleton_class.send(:attr_accessor, name)
24
62
  end
25
63
 
26
- define_variable :current_mailbox
27
- define_variable :current_summary
28
- define_variable :current_uid
29
- define_variable :current_mail
30
- define_variable :background_thread
64
+ define_variable :current_mailbox, attr: :accessor
65
+ define_variable :current_summary, attr: :accessor
66
+ define_variable :current_uid, attr: :accessor
67
+ define_variable :current_mail, attr: :accessor
68
+ define_variable :background_thread, attr: :accessor
69
+ define_variable :background_thread_mutex, initial_value: Mutex.new
70
+ define_variable :keep_alive_thread, attr: :accessor
71
+ define_variable :keep_alive_thread_mutex, initial_value: Mutex.new
72
+ define_variable :imap
73
+ define_variable :imap_mutex, initial_value: Mutex.new
74
+ define_variable :mailboxes, initial_value: []
75
+ define_variable :current_account
76
+ define_variable :account_config
77
+ define_variable :groonga_db
31
78
 
32
- def self.background
33
- if background_thread&.alive?
34
- raise EditorError, "Background thread already running"
79
+ def self.background(skip_if_busy: false)
80
+ @background_thread_mutex.synchronize do
81
+ if background_thread&.alive?
82
+ if skip_if_busy
83
+ return
84
+ else
85
+ raise EditorError, "Another background thread is running"
86
+ end
87
+ end
88
+ self.background_thread = Utils.background {
89
+ begin
90
+ yield
91
+ ensure
92
+ self.background_thread = nil
93
+ end
94
+ }
35
95
  end
36
- self.background_thread = Utils.background {
37
- begin
38
- yield
39
- ensure
40
- self.background_thread = nil
96
+ end
97
+
98
+ def self.start_keep_alive_thread
99
+ @keep_alive_thread_mutex.synchronize do
100
+ if keep_alive_thread
101
+ raise EditorError, "Keep alive thread already running"
41
102
  end
42
- }
103
+ self.keep_alive_thread = Thread.start {
104
+ loop do
105
+ sleep(CONFIG[:mournmail_keep_alive_interval])
106
+ background(skip_if_busy: true) do
107
+ begin
108
+ imap_connect do |imap|
109
+ imap.noop
110
+ end
111
+ rescue => e
112
+ message("Error in IMAP NOOP: #{e.class}: #{e.message}")
113
+ end
114
+ end
115
+ end
116
+ }
117
+ end
118
+ end
119
+
120
+ def self.stop_keep_alive_thread
121
+ @keep_alive_thread_mutex.synchronize do
122
+ if keep_alive_thread
123
+ keep_alive_thread&.kill
124
+ self.keep_alive_thread = nil
125
+ end
126
+ end
43
127
  end
44
128
 
45
129
  def self.message_window
@@ -74,19 +158,51 @@ module Mournmail
74
158
  escape_binary(s)
75
159
  end
76
160
 
77
- @imap = nil
78
- @imap_mutex = Mutex.new
161
+ def self.current_account
162
+ init_current_account
163
+ @current_account
164
+ end
165
+
166
+ def self.account_config
167
+ init_current_account
168
+ @account_config
169
+ end
170
+
171
+ def self.init_current_account
172
+ if @current_account.nil?
173
+ @current_account, @account_config = CONFIG[:mournmail_accounts].first
174
+ end
175
+ end
176
+
177
+ def self.current_account=(name)
178
+ unless CONFIG[:mournmail_accounts].key?(name)
179
+ raise ArgumentError, "No such account: #{name}"
180
+ end
181
+ @current_account = name
182
+ @account_config = CONFIG[:mournmail_accounts][name]
183
+ end
79
184
 
80
185
  def self.imap_connect
81
186
  @imap_mutex.synchronize do
187
+ if keep_alive_thread.nil?
188
+ start_keep_alive_thread
189
+ end
82
190
  if @imap.nil? || @imap.disconnected?
191
+ conf = account_config
192
+ auth_type = conf[:imap_options][:auth_type] || "PLAIN"
193
+ password = conf[:imap_options][:password]
194
+ if auth_type == "gmail"
195
+ auth_type = "XOAUTH2"
196
+ password = google_access_token
197
+ end
83
198
  Timeout.timeout(CONFIG[:mournmail_imap_connect_timeout]) do
84
- @imap = Net::IMAP.new(CONFIG[:mournmail_imap_host],
85
- CONFIG[:mournmail_imap_options])
86
- @imap.authenticate(CONFIG[:mournmail_imap_options][:auth_type] ||
87
- "PLAIN",
88
- CONFIG[:mournmail_imap_options][:user_name],
89
- CONFIG[:mournmail_imap_options][:password])
199
+ @imap = Net::IMAP.new(conf[:imap_host],
200
+ conf[:imap_options])
201
+ @imap.authenticate(auth_type, conf[:imap_options][:user_name],
202
+ password)
203
+ @mailboxes = @imap.list("", "*").map { |mbox|
204
+ Net::IMAP.decode_utf7(mbox.name)
205
+ }
90
206
  if Mournmail.current_mailbox
91
207
  @imap.select(Mournmail.current_mailbox)
92
208
  end
@@ -101,6 +217,7 @@ module Mournmail
101
217
 
102
218
  def self.imap_disconnect
103
219
  @imap_mutex.synchronize do
220
+ stop_keep_alive_thread
104
221
  if @imap
105
222
  @imap.disconnect rescue nil
106
223
  @imap = nil
@@ -108,58 +225,354 @@ module Mournmail
108
225
  end
109
226
  end
110
227
 
228
+ def self.google_access_token(account = current_account)
229
+ auth_path = File.expand_path("cache/#{account}/google_auth.json",
230
+ CONFIG[:mournmail_directory])
231
+ FileUtils.mkdir_p(File.dirname(auth_path))
232
+ store = Google::APIClient::FileStore.new(auth_path)
233
+ storage = Google::APIClient::Storage.new(store)
234
+ storage.authorize
235
+ if storage.authorization.nil?
236
+ conf = CONFIG[:mournmail_accounts][account]
237
+ path = File.expand_path(conf[:client_secret_path])
238
+ client_secrets = Google::APIClient::ClientSecrets.load(path)
239
+ auth_client = client_secrets.to_authorization
240
+ auth_client.update!(
241
+ :scope => 'https://mail.google.com/',
242
+ :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
243
+ )
244
+ auth_uri = auth_client.authorization_uri.to_s
245
+ auth_client.code = foreground! {
246
+ begin
247
+ Launchy.open(auth_uri)
248
+ rescue Launchy::CommandNotFoundError
249
+ buffer = show_google_auth_uri(auth_uri)
250
+ end
251
+ begin
252
+ Window.echo_area.clear_message
253
+ Window.redisplay
254
+ read_from_minibuffer("Code: ").chomp
255
+ ensure
256
+ if buffer
257
+ kill_buffer(buffer, force: true)
258
+ end
259
+ end
260
+ }
261
+ auth_client.fetch_access_token!
262
+ old_umask = File.umask(077)
263
+ begin
264
+ storage.write_credentials(auth_client)
265
+ ensure
266
+ File.umask(old_umask)
267
+ end
268
+ else
269
+ auth_client = storage.authorization
270
+ end
271
+ auth_client.access_token
272
+ end
273
+
274
+ def self.show_google_auth_uri(auth_uri)
275
+ buffer = Buffer.find_or_new("*message*",
276
+ undo_limit: 0, read_only: true)
277
+ buffer.apply_mode(Mournmail::MessageMode)
278
+ buffer.read_only_edit do
279
+ buffer.clear
280
+ buffer.insert(<<~EOF)
281
+ Open the following URI in your browser and type obtained code:
282
+
283
+ #{auth_uri}
284
+ EOF
285
+ end
286
+ window = Mournmail.message_window
287
+ window.buffer = buffer
288
+ buffer
289
+ end
290
+
291
+ def self.xoauth2_string(user, access_token)
292
+ "user=#{user}\1auth=Bearer #{access_token}\1\1"
293
+ end
294
+
111
295
  def self.fetch_summary(mailbox, all: false)
296
+ if all
297
+ summary = Mournmail::Summary.new(mailbox)
298
+ else
299
+ summary = Mournmail::Summary.load_or_new(mailbox)
300
+ end
112
301
  imap_connect do |imap|
113
302
  imap.select(mailbox)
114
- if all
115
- summary = Mournmail::Summary.new(mailbox)
116
- else
117
- summary = Mournmail::Summary.load_or_new(mailbox)
303
+ uidvalidity = imap.responses["UIDVALIDITY"].last
304
+ if uidvalidity && summary.uidvalidity &&
305
+ uidvalidity != summary.uidvalidity
306
+ clear = foreground! {
307
+ yes_or_no?("UIDVALIDITY has been changed; Clear cache?")
308
+ }
309
+ if clear
310
+ summary = Mournmail::Summary.new(mailbox)
311
+ end
118
312
  end
119
- first_uid = (summary.last_uid || 0) + 1
120
- data = imap.uid_fetch(first_uid..-1, ["UID", "ENVELOPE", "FLAGS"])
313
+ summary.uidvalidity = uidvalidity
314
+ uids = imap.uid_search("ALL")
315
+ new_uids = uids - summary.uids
316
+ return summary if new_uids.empty?
121
317
  summary.synchronize do
122
- data&.each do |i|
123
- uid = i.attr["UID"]
124
- next if summary[uid]
125
- env = i.attr["ENVELOPE"]
126
- flags = i.attr["FLAGS"]
127
- item = Mournmail::SummaryItem.new(uid, env.date, env.from,
128
- env.subject, flags)
129
- summary.add_item(item, env.message_id, env.in_reply_to)
318
+ new_uids.each_slice(1000) do |uid_chunk|
319
+ data = imap.uid_fetch(uid_chunk, ["UID", "ENVELOPE", "FLAGS"])
320
+ data&.each do |i|
321
+ uid = i.attr["UID"]
322
+ next if summary[uid]
323
+ env = i.attr["ENVELOPE"]
324
+ flags = i.attr["FLAGS"]
325
+ item = Mournmail::SummaryItem.new(uid, env.date, env.from,
326
+ env.subject, flags)
327
+ summary.add_item(item, env.message_id, env.in_reply_to)
328
+ end
130
329
  end
131
330
  end
132
331
  summary
133
332
  end
333
+ rescue SocketError, Timeout::Error => e
334
+ foreground do
335
+ message(e.message)
336
+ end
337
+ summary
338
+ end
339
+
340
+ def self.show_summary(summary)
341
+ buffer = Buffer.find_or_new("*summary*", undo_limit: 0,
342
+ read_only: true)
343
+ buffer.apply_mode(Mournmail::SummaryMode)
344
+ buffer.read_only_edit do
345
+ buffer.clear
346
+ buffer.insert(summary.to_s)
347
+ end
348
+ switch_to_buffer(buffer)
349
+ Mournmail.current_mailbox = summary.mailbox
350
+ Mournmail.current_summary = summary
351
+ Mournmail.current_mail = nil
352
+ Mournmail.current_uid = nil
353
+ begin
354
+ buffer.beginning_of_buffer
355
+ buffer.re_search_forward(/^ *\d+ u/)
356
+ rescue SearchError
357
+ buffer.end_of_buffer
358
+ buffer.re_search_backward(/^ *\d+ /, raise_error: false)
359
+ end
360
+ summary_read_command
134
361
  end
135
362
 
136
363
  def self.mailbox_cache_path(mailbox)
137
- dir = CONFIG[:mournmail_directory]
138
- host = CONFIG[:mournmail_imap_host]
139
- File.expand_path("cache/#{host}/#{mailbox}", dir)
364
+ File.expand_path("cache/#{current_account}/mailboxes/#{mailbox}",
365
+ CONFIG[:mournmail_directory])
366
+ end
367
+
368
+ def self.mail_cache_path(cache_id)
369
+ dir = cache_id[0, 2]
370
+ File.expand_path("cache/#{current_account}/mails/#{dir}/#{cache_id}",
371
+ CONFIG[:mournmail_directory])
140
372
  end
141
373
 
142
- def self.read_mail(mailbox, uid)
143
- path = File.join(mailbox_cache_path(mailbox), uid.to_s)
374
+ def self.read_mail_cache(cache_id)
375
+ path = Mournmail.mail_cache_path(cache_id)
376
+ File.read(path)
377
+ end
378
+
379
+ def self.write_mail_cache(s)
380
+ header = s.slice(/.*\r\n\r\n/m)
381
+ cache_id = Digest::SHA256.hexdigest(header)
382
+ path = mail_cache_path(cache_id)
383
+ dir = File.dirname(path)
384
+ base = File.basename(path)
144
385
  begin
145
- File.open(path) do |f|
146
- f.flock(File::LOCK_SH)
147
- [f.read, false]
386
+ f = Tempfile.create(["#{base}-", ".tmp"], dir,
387
+ external_encoding: "ASCII-8BIT", binmode: true)
388
+ begin
389
+ f.write(s)
390
+ ensure
391
+ f.close
148
392
  end
149
393
  rescue Errno::ENOENT
150
- imap_connect do |imap|
151
- imap.select(mailbox)
152
- data = imap.uid_fetch(uid, "BODY[]")
153
- if data.empty?
154
- raise EditorError, "No such mail: #{uid}"
394
+ FileUtils.mkdir_p(File.dirname(path))
395
+ retry
396
+ end
397
+ File.rename(f.path, path)
398
+ cache_id
399
+ end
400
+
401
+ def self.index_mail(cache_id, mail)
402
+ messages_db = Groonga["Messages"]
403
+ unless messages_db.has_key?(cache_id)
404
+ thread_id = find_thread_id(mail, messages_db)
405
+ list_id = (mail["List-Id"] || mail["X-ML-Name"])
406
+ messages_db.add(cache_id,
407
+ message_id: header_text(mail.message_id),
408
+ thread_id: header_text(thread_id),
409
+ date: mail.date&.to_time,
410
+ subject: header_text(mail.subject),
411
+ from: header_text(mail["From"]),
412
+ to: header_text(mail["To"]),
413
+ cc: header_text(mail["Cc"]),
414
+ list_id: header_text(list_id),
415
+ body: body_text(mail))
416
+ end
417
+ end
418
+
419
+ class << self
420
+ private
421
+
422
+ def find_thread_id(mail, messages_db)
423
+ references = Array(mail.references) | Array(mail.in_reply_to)
424
+ if references.empty?
425
+ mail.message_id
426
+ elsif /\Aredmine\.issue-/.match?(references.first)
427
+ references.first
428
+ else
429
+ parent = messages_db.select { |m|
430
+ references.inject(nil) { |cond, ref|
431
+ if cond.nil?
432
+ m.message_id == ref
433
+ else
434
+ cond | (m.message_id == ref)
435
+ end
436
+ }
437
+ }.first
438
+ if parent
439
+ parent.thread_id
440
+ else
441
+ mail.message_id
155
442
  end
156
- s = data[0].attr["BODY[]"]
157
- FileUtils.mkdir_p(File.dirname(path))
158
- File.open(path, "w", 0600) do |f|
159
- f.flock(File::LOCK_EX)
160
- f.write(s)
443
+ end
444
+ end
445
+
446
+ def header_text(s)
447
+ force_utf8(s.to_s)
448
+ end
449
+
450
+ def body_text(mail)
451
+ if mail.multipart?
452
+ mail.parts.map { |part|
453
+ part_text(part)
454
+ }.join("\n")
455
+ else
456
+ s = mail.body.decoded
457
+ to_utf8(s, mail.charset).gsub(/\r\n/, "\n")
458
+ end
459
+ rescue
460
+ ""
461
+ end
462
+
463
+ def part_text(part)
464
+ if part.multipart?
465
+ part.parts.map { |part|
466
+ part_text(part)
467
+ }.join("\n")
468
+ elsif part.main_type == "message" && part.sub_type == "rfc822"
469
+ mail = Mail.new(part.body.raw_source)
470
+ body_text(mail)
471
+ elsif part.attachment?
472
+ force_utf8(part.filename.to_s)
473
+ else
474
+ if part.main_type == "text" && part.sub_type == "plain"
475
+ force_utf8(part.decoded).sub(/(?<!\n)\z/, "\n").gsub(/\r\n/, "\n")
476
+ else
477
+ ""
161
478
  end
162
- [s, true]
479
+ end
480
+ rescue
481
+ ""
482
+ end
483
+ end
484
+
485
+ def self.read_mailbox_name(prompt, **opts)
486
+ f = ->(s) {
487
+ complete_for_minibuffer(s, @mailboxes)
488
+ }
489
+ mailbox = read_from_minibuffer(prompt, completion_proc: f, **opts)
490
+ Net::IMAP.encode_utf7(mailbox)
491
+ end
492
+
493
+ def self.force_utf8(s)
494
+ s.dup.force_encoding(Encoding::UTF_8).scrub("?")
495
+ end
496
+
497
+ def self.to_utf8(s, charset)
498
+ if /\Autf-8\z/i.match?(charset)
499
+ force_utf8(s)
500
+ else
501
+ begin
502
+ s.encode(Encoding::UTF_8, charset, replace: "?")
503
+ rescue
504
+ force_utf8(NKF.nkf("-w", s))
505
+ end
506
+ end.gsub(/\r\n/, "\n")
507
+ end
508
+
509
+ def self.open_groonga_db
510
+ db_path = File.expand_path("groonga/#{current_account}/messages.db",
511
+ CONFIG[:mournmail_directory])
512
+ if File.exist?(db_path)
513
+ @groonga_db = Groonga::Database.open(db_path)
514
+ else
515
+ @groonga_db = create_groonga_db(db_path)
516
+ end
517
+ end
518
+
519
+ def self.create_groonga_db(db_path)
520
+ FileUtils.mkdir_p(File.dirname(db_path), mode: 0700)
521
+ db = Groonga::Database.create(path: db_path)
522
+
523
+ Groonga::Schema.create_table("Messages", :type => :hash) do |table|
524
+ table.short_text("message_id")
525
+ table.short_text("thread_id")
526
+ table.time("date")
527
+ table.short_text("subject")
528
+ table.short_text("from")
529
+ table.short_text("to")
530
+ table.short_text("cc")
531
+ table.short_text("list_id")
532
+ table.text("body")
533
+ end
534
+
535
+ Groonga::Schema.create_table("Terms",
536
+ type: :patricia_trie,
537
+ normalizer: :NormalizerAuto,
538
+ default_tokenizer: "TokenBigram") do |table|
539
+ table.index("Messages.subject")
540
+ table.index("Messages.from")
541
+ table.index("Messages.to")
542
+ table.index("Messages.cc")
543
+ table.index("Messages.list_id")
544
+ table.index("Messages.body")
545
+ end
546
+
547
+ db
548
+ end
549
+
550
+ def self.close_groonga_db
551
+ if @groonga_db
552
+ @groonga_db.close
553
+ end
554
+ end
555
+
556
+ def self.parse_mail(s)
557
+ Mail.new(s.scrub("??"))
558
+ end
559
+
560
+ def self.read_account_name(prompt, **opts)
561
+ f = ->(s) {
562
+ complete_for_minibuffer(s, CONFIG[:mournmail_accounts].keys)
563
+ }
564
+ read_from_minibuffer(prompt, completion_proc: f, **opts)
565
+ end
566
+
567
+ def self.insert_signature
568
+ account = Buffer.current[:mournmail_delivery_account] ||
569
+ Mournmail.current_account
570
+ signature = CONFIG[:mournmail_accounts][account][:signature]
571
+ if signature
572
+ Buffer.current.save_excursion do
573
+ end_of_buffer
574
+ insert("\n")
575
+ insert(signature)
163
576
  end
164
577
  end
165
578
  end