mournmail 0.1.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.
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "fileutils"
5
+ require "monitor"
6
+
7
+ module Mournmail
8
+ class Summary
9
+ attr_reader :items, :last_uid
10
+
11
+ include MonitorMixin
12
+
13
+ def self.cache_path(mailbox)
14
+ File.expand_path("cache/#{mailbox}/.summary",
15
+ CONFIG[:mournmail_directory])
16
+ end
17
+
18
+ def self.load(mailbox)
19
+ File.open(cache_path(mailbox)) { |f|
20
+ f.flock(File::LOCK_SH)
21
+ Marshal.load(f)
22
+ }
23
+ end
24
+
25
+ def self.load_or_new(mailbox)
26
+ load(mailbox)
27
+ rescue Errno::ENOENT
28
+ new(mailbox)
29
+ end
30
+
31
+ def initialize(mailbox)
32
+ super()
33
+ @mailbox = mailbox
34
+ @items = []
35
+ @message_id_table = {}
36
+ @uid_table = {}
37
+ @last_uid = nil
38
+ end
39
+
40
+ DUMPABLE_VARIABLES = [
41
+ :@mailbox,
42
+ :@items,
43
+ :@message_id_table,
44
+ :@uid_table,
45
+ :@last_uid
46
+ ]
47
+
48
+ def marshal_dump
49
+ DUMPABLE_VARIABLES.each_with_object({}) { |var, h|
50
+ h[var] = instance_variable_get(var)
51
+ }
52
+ end
53
+
54
+ def marshal_load(data)
55
+ mon_initialize
56
+ data.each do |var, val|
57
+ instance_variable_set(var, val)
58
+ end
59
+ end
60
+
61
+ def add_item(item, message_id, in_reply_to)
62
+ synchronize do
63
+ parent = @message_id_table[in_reply_to]
64
+ if parent
65
+ parent.add_reply(item)
66
+ else
67
+ @items.push(item)
68
+ end
69
+ if message_id
70
+ @message_id_table[message_id] = item
71
+ end
72
+ @uid_table[item.uid] = item
73
+ @last_uid = item.uid
74
+ end
75
+ end
76
+
77
+ def delete_item_if(&block)
78
+ synchronize do
79
+ @items = @items.flat_map { |item|
80
+ item.delete_reply_if(&block)
81
+ if yield(item)
82
+ item.replies
83
+ else
84
+ [item]
85
+ end
86
+ }
87
+ end
88
+ end
89
+
90
+ def [](uid)
91
+ synchronize do
92
+ @uid_table[uid]
93
+ end
94
+ end
95
+
96
+ def save
97
+ synchronize do
98
+ path = Summary.cache_path(@mailbox)
99
+ FileUtils.mkdir_p(File.dirname(path))
100
+ File.open(Summary.cache_path(@mailbox), "w", 0600) do |f|
101
+ f.flock(File::LOCK_EX)
102
+ Marshal.dump(self, f)
103
+ end
104
+ end
105
+ end
106
+
107
+ def to_s
108
+ synchronize do
109
+ items.each_with_object(String.new) do |item, s|
110
+ s << item.to_s
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ class SummaryItem
117
+ attr_reader :uid, :date, :from, :subject, :flags
118
+ attr_reader :replies
119
+
120
+ def initialize(uid, date, from, subject, flags)
121
+ @uid = uid
122
+ @date = date
123
+ @from = from
124
+ @subject = subject
125
+ @flags = flags
126
+ @line = nil
127
+ @replies = []
128
+ end
129
+
130
+ def add_reply(reply)
131
+ @replies << reply
132
+ end
133
+
134
+ def delete_reply_if(&block)
135
+ @replies = @replies.flat_map { |reply|
136
+ reply.delete_reply_if(&block)
137
+ if yield(reply)
138
+ reply.replies
139
+ else
140
+ [reply]
141
+ end
142
+ }
143
+ end
144
+
145
+ def to_s(limit = 78, from_limit = 16, level = 0)
146
+ @line ||= format_line(limit, from_limit, level)
147
+ return @line if @replies.empty?
148
+ s = @line.dup
149
+ child_level = level + 1
150
+ @replies.each do |reply|
151
+ s << reply.to_s(limit, from_limit, child_level)
152
+ end
153
+ s
154
+ end
155
+
156
+ def set_flag(flag, update_server: true)
157
+ if !@flags.include?(flag)
158
+ update_flag("+", flag, update_server: update_server)
159
+ end
160
+ end
161
+
162
+ def unset_flag(flag, update_server: true)
163
+ if @flags.include?(flag)
164
+ update_flag("-", flag, update_server: update_server)
165
+ end
166
+ end
167
+
168
+ def toggle_flag(flag, update_server: true)
169
+ sign = @flags.include?(flag) ? "-" : "+"
170
+ update_flag(sign, flag, update_server: update_server)
171
+ end
172
+
173
+ def flags_char
174
+ format_flags(@flags)
175
+ end
176
+
177
+ private
178
+
179
+ def format_line(limit = 78, from_limit = 16, level = 0)
180
+ space = " " * (level < 8 ? level : 8)
181
+ s = String.new
182
+ s << format("%s %s%s %s[ %s ] ",
183
+ @uid, format_flags(@flags), format_date(@date), space,
184
+ ljust(format_from(@from), from_limit))
185
+ s << ljust(decode_eword(@subject.to_s), limit - Buffer.display_width(s))
186
+ s << "\n"
187
+ s
188
+ end
189
+
190
+ def ljust(s, n)
191
+ width = 0
192
+ str = String.new
193
+ s.each_char do |c|
194
+ w = Buffer.display_width(c)
195
+ width += w
196
+ if width > n
197
+ width -= w
198
+ break
199
+ end
200
+ str.concat(c)
201
+ break if width == n
202
+ end
203
+ str + " " * (n - width)
204
+ end
205
+
206
+ def format_flags(flags)
207
+ if flags.include?(:Deleted)
208
+ "d"
209
+ elsif flags.include?(:Flagged)
210
+ "$"
211
+ elsif flags.include?(:Answered)
212
+ "a"
213
+ elsif !flags.include?(:Seen)
214
+ "u"
215
+ else
216
+ " "
217
+ end
218
+ end
219
+
220
+ def format_date(date)
221
+ (Time.parse(date) rescue Time.at(0)).localtime.strftime("%m/%d %H:%M")
222
+ end
223
+
224
+ def format_from(from)
225
+ addr = from&.[](0)
226
+ if addr.nil? || addr.mailbox.nil?
227
+ return "Unknown sender"
228
+ end
229
+ mailbox = Mournmail.escape_binary(addr.mailbox)
230
+ host = Mournmail.escape_binary(addr.host.to_s)
231
+ if addr.name
232
+ "#{decode_eword(addr.name)} <#{mailbox}@#{host}>"
233
+ else
234
+ "#{mailbox}@#{host}"
235
+ end
236
+ end
237
+
238
+ def decode_eword(s)
239
+ Mournmail.decode_eword(s)
240
+ end
241
+
242
+ def update_flag(sign, flag, update_server: true)
243
+ if update_server
244
+ Mournmail.imap_connect do |imap|
245
+ data = imap.uid_store(@uid, "#{sign}FLAGS", [flag]).first
246
+ @flags = data.attr["FLAGS"]
247
+ end
248
+ else
249
+ case
250
+ when "+"
251
+ @flags.push(flag)
252
+ when "-"
253
+ @flags.delete(flag)
254
+ end
255
+ end
256
+ if @line
257
+ s = format("%s %s", @uid, format_flags(@flags))
258
+ @line.sub!(/^\d+ ./, s)
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ using Mournmail::MessageRendering
6
+
7
+ module Mournmail
8
+ class SummaryMode < Textbringer::Mode
9
+ SUMMARY_MODE_MAP = Keymap.new
10
+ SUMMARY_MODE_MAP.define_key("s", :mournmail_summary_sync)
11
+ SUMMARY_MODE_MAP.define_key(" ", :summary_read_command)
12
+ SUMMARY_MODE_MAP.define_key(:backspace, :summary_scroll_down_command)
13
+ SUMMARY_MODE_MAP.define_key("\C-h", :summary_scroll_down_command)
14
+ SUMMARY_MODE_MAP.define_key("\C-?", :summary_scroll_down_command)
15
+ SUMMARY_MODE_MAP.define_key("n", :summary_next_command)
16
+ SUMMARY_MODE_MAP.define_key("w", :summary_write_command)
17
+ SUMMARY_MODE_MAP.define_key("a", :summary_reply_command)
18
+ SUMMARY_MODE_MAP.define_key("A", :summary_reply_command)
19
+ SUMMARY_MODE_MAP.define_key("f", :summary_forward_command)
20
+ SUMMARY_MODE_MAP.define_key("u", :summary_toggle_seen_command)
21
+ SUMMARY_MODE_MAP.define_key("$", :summary_toggle_flagged_command)
22
+ SUMMARY_MODE_MAP.define_key("d", :summary_toggle_deleted_command)
23
+ SUMMARY_MODE_MAP.define_key("x", :summary_expunge_command)
24
+ SUMMARY_MODE_MAP.define_key("v", :summary_view_source_command)
25
+ SUMMARY_MODE_MAP.define_key("q", :mournmail_quit)
26
+ SUMMARY_MODE_MAP.define_key("k", :previous_line)
27
+ SUMMARY_MODE_MAP.define_key("j", :next_line)
28
+ SUMMARY_MODE_MAP.define_key("m", :mournmail_visit_mailbox)
29
+
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.*/
35
+
36
+ def initialize(buffer)
37
+ super(buffer)
38
+ buffer.keymap = SUMMARY_MODE_MAP
39
+ end
40
+
41
+ define_local_command(:summary_read, doc: "Read a mail.") do
42
+ uid = scroll_up_or_next_uid
43
+ return if uid.nil?
44
+ Mournmail.background do
45
+ mailbox = Mournmail.current_mailbox
46
+ mail = Mail.new(Mournmail.read_mail(mailbox, uid))
47
+ message = mail.render
48
+ next_tick do
49
+ message_buffer = Buffer.find_or_new("*message*",
50
+ undo_limit: 0, read_only: true)
51
+ message_buffer.apply_mode(Mournmail::MessageMode)
52
+ message_buffer.read_only_edit do
53
+ message_buffer.clear
54
+ message_buffer.insert(message)
55
+ message_buffer.beginning_of_buffer
56
+ end
57
+ window = Mournmail.message_window
58
+ window.buffer = message_buffer
59
+ mark_as_seen(uid)
60
+ Mournmail.current_uid = uid
61
+ Mournmail.current_mail = mail
62
+ end
63
+ end
64
+ end
65
+
66
+ define_local_command(:summary_scroll_down,
67
+ doc: "Scroll down the current message.") do
68
+ uid = selected_uid
69
+ if uid == Mournmail.current_uid
70
+ window = Mournmail.message_window
71
+ if window.buffer.name == "*message*"
72
+ old_window = Window.current
73
+ begin
74
+ Window.current = window
75
+ scroll_down
76
+ return
77
+ ensure
78
+ Window.current = old_window
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ define_local_command(:summary_next,
85
+ doc: "Display the next mail.") do
86
+ next_message
87
+ summary_read
88
+ end
89
+
90
+ define_local_command(:summary_write,
91
+ doc: "Write a new mail.") do
92
+ Window.current = Mournmail.message_window
93
+ Commands.mail
94
+ end
95
+
96
+ define_local_command(:summary_reply,
97
+ doc: "Reply to the current message.") do
98
+ |reply_all = current_prefix_arg|
99
+ uid = selected_uid
100
+ Mournmail.background do
101
+ mailbox = Mournmail.current_mailbox
102
+ mail = Mail.new(Mournmail.read_mail(mailbox, uid))
103
+ body = mail.render_body
104
+ next_tick do
105
+ Window.current = Mournmail.message_window
106
+ Commands.mail(run_hooks: false)
107
+ if reply_all
108
+ insert(mail.from&.join(", "))
109
+ cc_addrs = [mail.reply_to, mail.to, mail.cc].flat_map { |addrs|
110
+ addrs || []
111
+ }.uniq.reject { |addr|
112
+ mail.from&.include?(addr)
113
+ }
114
+ insert("\nCc: " + cc_addrs.join(", "))
115
+ else
116
+ insert(mail.reply_to&.join(", ") || mail.from&.join(", "))
117
+ end
118
+ re_search_forward(/^Subject: /)
119
+ subject = mail["subject"].to_s
120
+ if /\Are:/i !~ subject
121
+ insert("Re: ")
122
+ end
123
+ insert(subject)
124
+ if mail['message-id']
125
+ insert("\nIn-Reply-To: #{mail['message-id']}")
126
+ end
127
+ end_of_buffer
128
+ push_mark
129
+ insert(<<~EOF + body.gsub(/^/, "> "))
130
+
131
+
132
+ On #{mail['date']}
133
+ #{mail['from']} wrote:
134
+ EOF
135
+ exchange_point_and_mark
136
+ run_hooks(:mournmail_draft_setup_hook)
137
+ end
138
+ end
139
+ end
140
+
141
+ define_local_command(:summary_forward,
142
+ doc: "Forward the current message.") do
143
+ uid = selected_uid
144
+ summary = Mournmail.current_summary
145
+ item = summary[uid]
146
+ Window.current = Mournmail.message_window
147
+ Commands.mail
148
+ re_search_forward(/^Subject: /)
149
+ insert("Forward: " + Mournmail.decode_eword(item.subject))
150
+ insert("\nAttached-Message: #{Mournmail.current_mailbox}/#{uid}")
151
+ re_search_backward(/^To: /)
152
+ end_of_line
153
+ end
154
+
155
+ define_local_command(:summary_toggle_seen,
156
+ doc: "Toggle Seen.") do
157
+ toggle_flag(selected_uid, :Seen)
158
+ end
159
+
160
+ define_local_command(:summary_toggle_flagged,
161
+ doc: "Toggle Flagged.") do
162
+ toggle_flag(selected_uid, :Flagged)
163
+ end
164
+
165
+ define_local_command(:summary_toggle_deleted,
166
+ doc: <<~EOD) do
167
+ Toggle Deleted. Type `x` to expunge deleted messages.
168
+ EOD
169
+ toggle_flag(selected_uid, :Deleted)
170
+ end
171
+
172
+ define_local_command(:summary_expunge,
173
+ doc: <<~EOD) do
174
+ Expunge deleted messages.
175
+ EOD
176
+ buffer = Buffer.current
177
+ Mournmail.background do
178
+ Mournmail.imap_connect do |imap|
179
+ imap.expunge
180
+ end
181
+ summary = Mournmail.current_summary
182
+ summary.delete_item_if do |item|
183
+ item.flags.include?(:Deleted)
184
+ end
185
+ summary_text = summary.to_s
186
+ summary.save
187
+ next_tick do
188
+ buffer.read_only_edit do
189
+ buffer.clear
190
+ buffer.insert(summary_text)
191
+ end
192
+ message("Expunged messages")
193
+ end
194
+ end
195
+ end
196
+
197
+ define_local_command(:summary_view_source,
198
+ doc: "View source of a mail.") do
199
+ uid = selected_uid
200
+ Mournmail.background do
201
+ mailbox = Mournmail.current_mailbox
202
+ source = Mournmail.read_mail(mailbox, uid)
203
+ next_tick do
204
+ source_buffer = Buffer.find_or_new("*message-source*",
205
+ file_encoding: "ascii-8bit",
206
+ undo_limit: 0, read_only: true)
207
+ source_buffer.read_only_edit do
208
+ source_buffer.clear
209
+ source_buffer.insert(source.gsub(/\r\n/, "\n"))
210
+ source_buffer.file_format = :dos
211
+ source_buffer.beginning_of_buffer
212
+ end
213
+ window = Mournmail.message_window
214
+ window.buffer = source_buffer
215
+ end
216
+ end
217
+ end
218
+
219
+ private
220
+
221
+ def selected_uid
222
+ uid = @buffer.save_excursion {
223
+ @buffer.beginning_of_line
224
+ if !@buffer.looking_at?(/\d+/)
225
+ Mournmail.current_mail = nil
226
+ Mournmail.current_uid = nil
227
+ raise EditorError, "No message found"
228
+ end
229
+ match_string(0).to_i
230
+ }
231
+ end
232
+
233
+ def scroll_up_or_next_uid
234
+ begin
235
+ uid = selected_uid
236
+ if uid == Mournmail.current_uid
237
+ window = Mournmail.message_window
238
+ if window.buffer.name == "*message*"
239
+ old_window = Window.current
240
+ begin
241
+ Window.current = window
242
+ scroll_up
243
+ return nil
244
+ ensure
245
+ Window.current = old_window
246
+ end
247
+ end
248
+ end
249
+ uid
250
+ rescue RangeError # may be raised by scroll_up
251
+ next_message
252
+ retry
253
+ end
254
+ end
255
+
256
+ def mark_as_seen(uid)
257
+ summary_item = Mournmail.current_summary[uid]
258
+ if summary_item && !summary_item.flags.include?(:Seen)
259
+ summary_item.set_flag(:Seen, update_server: false)
260
+ Mournmail.current_summary.save
261
+ update_flags(summary_item)
262
+ end
263
+ end
264
+
265
+ def toggle_flag(uid, flag)
266
+ summary_item = Mournmail.current_summary[uid]
267
+ if summary_item
268
+ Mournmail.background do
269
+ summary_item.toggle_flag(flag)
270
+ Mournmail.current_summary.save
271
+ next_tick do
272
+ update_flags(summary_item)
273
+ end
274
+ end
275
+ end
276
+ end
277
+
278
+ def update_flags(summary_item)
279
+ @buffer.read_only_edit do
280
+ @buffer.save_excursion do
281
+ @buffer.beginning_of_buffer
282
+ uid = summary_item.uid
283
+ flags_char = summary_item.flags_char
284
+ if @buffer.re_search_forward(/^#{uid} ./)
285
+ @buffer.replace_match("#{uid} #{flags_char}")
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ def next_message
292
+ @buffer.end_of_line
293
+ if @buffer.end_of_buffer?
294
+ raise EditorError, "No more mail"
295
+ end
296
+ begin
297
+ @buffer.re_search_forward(/^\d+ u/)
298
+ rescue SearchError
299
+ @buffer.forward_line
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+ require "mail-iso-2022-jp"
5
+ require "net/imap"
6
+ require "time"
7
+ require "fileutils"
8
+ require "timeout"
9
+
10
+ module Mournmail
11
+ begin
12
+ require "mail-gpg"
13
+ HAVE_MAIL_GPG = true
14
+ rescue LoadError
15
+ HAVE_MAIL_GPG = false
16
+ end
17
+
18
+ def self.define_variable(name, value = nil)
19
+ var_name = "@" + name.to_s
20
+ if !instance_variable_defined?(var_name)
21
+ instance_variable_set(var_name, value)
22
+ end
23
+ singleton_class.send(:attr_accessor, name)
24
+ end
25
+
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
31
+
32
+ def self.background
33
+ if background_thread&.alive?
34
+ raise EditorError, "Background thread already running"
35
+ end
36
+ self.background_thread = Utils.background {
37
+ begin
38
+ yield
39
+ ensure
40
+ self.background_thread = nil
41
+ end
42
+ }
43
+ end
44
+
45
+ def self.message_window
46
+ if Window.list.size == 1
47
+ split_window
48
+ shrink_window(Window.current.lines - 8)
49
+ end
50
+ windows = Window.list
51
+ i = (windows.index(Window.current) + 1) % windows.size
52
+ windows[i]
53
+ end
54
+
55
+ def self.back_to_summary
56
+ summary_window = Window.list.find { |window|
57
+ window.buffer.name == "*summary*"
58
+ }
59
+ if summary_window
60
+ Window.current = summary_window
61
+ end
62
+ end
63
+
64
+ def self.escape_binary(s)
65
+ s.b.gsub(/[\x80-\xff]/n) { |c|
66
+ "<%02X>" % c.ord
67
+ }
68
+ end
69
+
70
+ def self.decode_eword(s)
71
+ Mail::Encodings.decode_encode(s, :decode).
72
+ encode(Encoding::UTF_8, replace: "?").gsub(/[\t\n]/, " ")
73
+ rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError
74
+ escape_binary(s)
75
+ end
76
+
77
+ @imap = nil
78
+ @imap_mutex = Mutex.new
79
+
80
+ def self.imap_connect
81
+ @imap_mutex.synchronize do
82
+ if @imap.nil? || @imap.disconnected?
83
+ 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])
90
+ if Mournmail.current_mailbox
91
+ @imap.select(Mournmail.current_mailbox)
92
+ end
93
+ end
94
+ end
95
+ yield(@imap)
96
+ end
97
+ rescue IOError, Errno::ECONNRESET
98
+ imap_disconnect
99
+ raise
100
+ end
101
+
102
+ def self.imap_disconnect
103
+ @imap_mutex.synchronize do
104
+ if @imap
105
+ @imap.disconnect rescue nil
106
+ @imap = nil
107
+ end
108
+ end
109
+ end
110
+
111
+ def self.fetch_summary(mailbox, all: false)
112
+ imap_connect do |imap|
113
+ imap.select(mailbox)
114
+ if all
115
+ summary = Mournmail::Summary.new(mailbox)
116
+ else
117
+ summary = Mournmail::Summary.load_or_new(mailbox)
118
+ end
119
+ first_uid = (summary.last_uid || 0) + 1
120
+ data = imap.uid_fetch(first_uid..-1, ["UID", "ENVELOPE", "FLAGS"])
121
+ 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)
130
+ end
131
+ end
132
+ summary
133
+ end
134
+ end
135
+
136
+ def self.read_mail(mailbox, uid)
137
+ path = File.expand_path("cache/#{mailbox}/#{uid}",
138
+ CONFIG[:mournmail_directory])
139
+ begin
140
+ File.open(path) do |f|
141
+ f.flock(File::LOCK_SH)
142
+ f.read
143
+ end
144
+ rescue Errno::ENOENT
145
+ imap_connect do |imap|
146
+ imap.select(mailbox)
147
+ data = imap.uid_fetch(uid, "BODY[]")
148
+ if data.empty?
149
+ raise EditorError, "No such mail: #{uid}"
150
+ end
151
+ s = data[0].attr["BODY[]"]
152
+ FileUtils.mkdir_p(File.dirname(path))
153
+ File.open(path, "w", 0600) do |f|
154
+ f.flock(File::LOCK_EX)
155
+ f.write(s)
156
+ end
157
+ s
158
+ end
159
+ end
160
+ end
161
+ end