vmail 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/vmail.rb ADDED
@@ -0,0 +1,45 @@
1
+ require 'vmail/imap_client'
2
+
3
+ module Vmail
4
+ extend self
5
+
6
+ def start
7
+ config = YAML::load(File.read(File.expand_path("~/gmail.yml")))
8
+ config.merge! 'logfile' => "vmail.log"
9
+
10
+ puts "starting vmail imap client with config #{config}"
11
+
12
+ drb_uri = Vmail::ImapClient.daemon config
13
+
14
+ server = DRbObject.new_with_uri drb_uri
15
+ server.window_width = `stty size`.strip.split(' ')[1]
16
+ server.select_mailbox ARGV.shift || 'INBOX'
17
+
18
+ query = ARGV.empty? ? [100, 'ALL'] : nil
19
+
20
+ buffer_file = "vmail-buffer.txt"
21
+ File.open(buffer_file, "w") do |file|
22
+ file.puts server.search(*query)
23
+ end
24
+
25
+ # invoke vim
26
+ # TODO
27
+ # - mvim; move viewer.vim to new file
28
+
29
+ vimscript = "viewer.vim"
30
+ system("DRB_URI='#{drb_uri}' vim -S #{vimscript} #{buffer_file}")
31
+
32
+ File.delete(buffer_file)
33
+
34
+ puts "closing imap connection"
35
+ begin
36
+ Timeout::timeout(5) do
37
+ $gmail.close
38
+ end
39
+ rescue Timeout::Error
40
+ puts "close connection attempt timed out"
41
+ end
42
+ puts "bye"
43
+ exit
44
+ end
45
+ end
@@ -0,0 +1,495 @@
1
+ require 'drb'
2
+ require 'vmail/message_formatter'
3
+ require 'vmail/string_ext'
4
+ require 'yaml'
5
+ require 'mail'
6
+ require 'net/imap'
7
+ require 'time'
8
+ require 'logger'
9
+
10
+ module Vmail
11
+ class ImapClient
12
+
13
+ MailboxAliases = { 'sent' => '[Gmail]/Sent Mail',
14
+ 'all' => '[Gmail]/All Mail',
15
+ 'starred' => '[Gmail]/Starred',
16
+ 'important' => '[Gmail]/Important',
17
+ 'drafts' => '[Gmail]/Drafts',
18
+ 'spam' => '[Gmail]/Spam',
19
+ 'trash' => '[Gmail]/Trash'
20
+ }
21
+
22
+ def initialize(config)
23
+ @username, @password = config['username'], config['password']
24
+ @name = config['name']
25
+ @signature = config['signature']
26
+ @mailbox = nil
27
+ @logger = Logger.new(config['logfile'] || STDERR)
28
+ @logger.level = Logger::DEBUG
29
+ @current_message = nil
30
+ end
31
+
32
+ def open
33
+ @imap = Net::IMAP.new('imap.gmail.com', 993, true, nil, false)
34
+ @imap.login(@username, @password)
35
+ end
36
+
37
+ def close
38
+ log "closing connection"
39
+ @imap.close rescue Net::IMAP::BadResponseError
40
+ @imap.disconnect
41
+ end
42
+
43
+ def select_mailbox(mailbox)
44
+ if MailboxAliases[mailbox]
45
+ mailbox = MailboxAliases[mailbox]
46
+ end
47
+ if mailbox == @mailbox
48
+ return
49
+ end
50
+ log "selecting mailbox #{mailbox.inspect}"
51
+ reconnect_if_necessary do
52
+ @imap.select(mailbox)
53
+ end
54
+ @mailbox = mailbox
55
+ @all_uids = []
56
+ @bad_uids = []
57
+ return "OK"
58
+ end
59
+
60
+ def revive_connection
61
+ log "reviving connection"
62
+ open
63
+ log "reselecting mailbox #@mailbox"
64
+ @imap.select(@mailbox)
65
+ end
66
+
67
+ def list_mailboxes
68
+ @mailboxes ||= (@imap.list("[Gmail]/", "%") + @imap.list("", "%")).
69
+ select {|struct| struct.attr.none? {|a| a == :Noselect} }.
70
+ map {|struct| struct.name}.
71
+ map {|name| MailboxAliases.invert[name] || name}
72
+ @mailboxes.delete("INBOX")
73
+ @mailboxes.unshift("INBOX")
74
+ @mailboxes.join("\n")
75
+ end
76
+
77
+ def fetch_headers(uid_set)
78
+ if uid_set.is_a?(String)
79
+ uid_set = uid_set.split(",").map(&:to_i)
80
+ elsif uid_set.is_a?(Integer)
81
+ uid_set = [uid_set]
82
+ end
83
+ max_uid = uid_set.max
84
+ log "fetch headers for #{uid_set.inspect}"
85
+ if uid_set.empty?
86
+ log "empty set"
87
+ return ""
88
+ end
89
+ results = reconnect_if_necessary do
90
+ @imap.uid_fetch(uid_set, ["FLAGS", "ENVELOPE", "RFC822.SIZE" ])
91
+ end
92
+ log "extracting headers"
93
+ lines = results.sort_by {|x| Time.parse(x.attr['ENVELOPE'].date)}.map {|x| format_header(x, max_uid)}
94
+ log "returning result"
95
+ return lines.join("\n")
96
+ end
97
+
98
+ def format_header(fetch_data, max_uid=nil)
99
+ uid = fetch_data.attr["UID"]
100
+ envelope = fetch_data.attr["ENVELOPE"]
101
+ size = fetch_data.attr["RFC822.SIZE"]
102
+ flags = fetch_data.attr["FLAGS"]
103
+ address_struct = if @mailbox == '[Gmail]/Sent Mail'
104
+ structs = envelope.to || envelope.cc
105
+ structs.nil? ? nil : structs.first
106
+ else
107
+ envelope.from.first
108
+ end
109
+ address = if address_struct.nil?
110
+ "unknown"
111
+ elsif address_struct.name
112
+ "#{Mail::Encodings.unquote_and_convert_to(address_struct.name, 'utf-8')} <#{[address_struct.mailbox, address_struct.host].join('@')}>"
113
+ else
114
+ [address_struct.mailbox, address_struct.host].join('@')
115
+ end
116
+ if @mailbox == '[Gmail]/Sent Mail' && envelope.to && envelope.cc
117
+ total_recips = (envelope.to + envelope.cc).size
118
+ address += " + #{total_recips - 1}"
119
+ end
120
+ date = Time.parse(envelope.date).localtime
121
+ date_formatted = if date.year != Time.now.year
122
+ date.strftime "%b %d %Y" rescue envelope.date.to_s
123
+ else
124
+ date.strftime "%b %d %I:%M%P" rescue envelope.date.to_s
125
+ end
126
+ subject = envelope.subject || ''
127
+ subject = Mail::Encodings.unquote_and_convert_to(subject, 'utf-8')
128
+ flags = format_flags(flags)
129
+ first_col_width = max_uid.to_s.length
130
+ mid_width = @width - (first_col_width + 14 + 2) - (10 + 2) - 5
131
+ address_col_width = (mid_width * 0.3).ceil
132
+ subject_col_width = (mid_width * 0.7).floor
133
+ [uid.to_s.col(first_col_width),
134
+ (date_formatted || '').col(14),
135
+ address.col(address_col_width),
136
+ subject.encode('utf-8').col(subject_col_width),
137
+ number_to_human_size(size).rcol(6),
138
+ flags.rcol(7)].join(' ')
139
+ end
140
+
141
+ UNITS = [:b, :kb, :mb, :gb].freeze
142
+
143
+ # borrowed from ActionView/Helpers
144
+ def number_to_human_size(number)
145
+ if number.to_i < 1024
146
+ "#{number} b"
147
+ else
148
+ max_exp = UNITS.size - 1
149
+ exponent = (Math.log(number) / Math.log(1024)).to_i # Convert to base 1024
150
+ exponent = max_exp if exponent > max_exp # we need this to avoid overflow for the highest unit
151
+ number /= 1024 ** exponent
152
+ unit = UNITS[exponent]
153
+ "#{number} #{unit}"
154
+ end
155
+ end
156
+
157
+
158
+ FLAGMAP = {:Flagged => '[*]'}
159
+ # flags is an array like [:Flagged, :Seen]
160
+ def format_flags(flags)
161
+ flags = flags.map {|flag| FLAGMAP[flag] || flag}
162
+ if flags.delete(:Seen).nil?
163
+ flags << '[+]' # unread
164
+ end
165
+ flags.join('')
166
+ end
167
+
168
+ def search(limit, *query)
169
+ log "uid_search limit: #{limit} query: #{@query.inspect}"
170
+ limit = 25 if limit.to_s !~ /^\d+$/
171
+ query = ['ALL'] if query.empty?
172
+ @query = query.join(' ')
173
+ log "uid_search #@query #{limit}"
174
+ @all_uids = reconnect_if_necessary do
175
+ @imap.uid_search(@query)
176
+ end
177
+ uids = @all_uids[-([limit.to_i, @all_uids.size].min)..-1] || []
178
+ res = fetch_headers(uids)
179
+ add_more_message_line(res, uids)
180
+ end
181
+
182
+ def update
183
+ reconnect_if_necessary(4) do
184
+ # this is just to prime the IMAP connection
185
+ # It's necessary for some reason.
186
+ log "priming connection for update"
187
+ res = @imap.uid_fetch(@all_uids[-1], ["ENVELOPE"])
188
+ if res.nil?
189
+ raise IOError, "IMAP connection seems broken"
190
+ end
191
+ end
192
+ uids = reconnect_if_necessary {
193
+ log "uid_search #@query"
194
+ @imap.uid_search(@query)
195
+ }
196
+ new_uids = uids - @all_uids
197
+ log "UPDATE: NEW UIDS: #{new_uids.inspect}"
198
+ if !new_uids.empty?
199
+ res = fetch_headers(new_uids)
200
+ @all_uids = uids
201
+ res
202
+ end
203
+ end
204
+
205
+ # gets 100 messages prior to uid
206
+ def more_messages(uid, limit=100)
207
+ uid = uid.to_i
208
+ x = [(@all_uids.index(uid) - limit), 0].max
209
+ y = [@all_uids.index(uid) - 1, 0].max
210
+ uids = @all_uids[x..y]
211
+ res = fetch_headers(uids)
212
+ add_more_message_line(res, uids)
213
+ end
214
+
215
+ def add_more_message_line(res, uids)
216
+ return res if uids.empty?
217
+ start_index = @all_uids.index(uids[0])
218
+ if start_index > 0
219
+ remaining = start_index
220
+ res = "> Load #{[100, remaining].min} more messages. #{remaining} remaining.\n" + res
221
+ end
222
+ res
223
+ end
224
+
225
+ def lookup(uid, raw=false, forwarded=false)
226
+ if raw
227
+ return @current_message.to_s
228
+ end
229
+ log "fetching #{uid.inspect}"
230
+ fetch_data = reconnect_if_necessary do
231
+ @imap.uid_fetch(uid.to_i, ["FLAGS", "RFC822", "RFC822.SIZE"])[0]
232
+ end
233
+ res = fetch_data.attr["RFC822"]
234
+ mail = Mail.new(res)
235
+ @current_message = mail # used later to show raw message or extract attachments if any
236
+ formatter = MessageFormatter.new(mail)
237
+ part = formatter.find_text_part
238
+ out = formatter.process_body
239
+ size = fetch_data.attr["RFC822.SIZE"]
240
+ message = <<-EOF
241
+ #{@mailbox} #{uid} #{number_to_human_size size} #{forwarded ? nil : format_parts_info(formatter.list_parts)}
242
+ ----------------------------------------
243
+ #{format_headers(formatter.extract_headers)}
244
+
245
+ #{out}
246
+ EOF
247
+ end
248
+
249
+ def format_parts_info(parts)
250
+ lines = parts.select {|part| part !~ %r{text/plain}}
251
+ if lines.size > 0
252
+ "\n#{lines.join("\n")}"
253
+ end
254
+ end
255
+
256
+ # uid_set is a string comming from the vim client
257
+ # action is -FLAGS or +FLAGS
258
+ def flag(uid_set, action, flg)
259
+ if uid_set.is_a?(String)
260
+ uid_set = uid_set.split(",").map(&:to_i)
261
+ end
262
+ # #<struct Net::IMAP::FetchData seqno=17423, attr={"FLAGS"=>[:Seen, "Flagged"], "UID"=>83113}>
263
+ log "flag #{uid_set} #{flg} #{action}"
264
+ if flg == 'Deleted'
265
+ # for delete, do in a separate thread because deletions are slow
266
+ Thread.new do
267
+ @imap.uid_copy(uid_set, "[Gmail]/Trash")
268
+ res = @imap.uid_store(uid_set, action, [flg.to_sym])
269
+ end
270
+ uid_set.each { |uid| @all_uids.delete(uid) }
271
+ elsif flg == '[Gmail]/Spam'
272
+ @imap.uid_copy(uid_set, "[Gmail]/Spam")
273
+ res = @imap.uid_store(uid_set, action, [:Deleted])
274
+ "#{uid} deleted"
275
+ else
276
+ log "Flagging"
277
+ res = @imap.uid_store(uid_set, action, [flg.to_sym])
278
+ # log res.inspect
279
+ fetch_headers(uid_set)
280
+ end
281
+ end
282
+
283
+ # uid_set is a string comming from the vim client
284
+ def move_to(uid_set, mailbox)
285
+ if MailboxAliases[mailbox]
286
+ mailbox = MailboxAliases[mailbox]
287
+ end
288
+ log "move_to #{uid_set.inspect} #{mailbox}"
289
+ if uid_set.is_a?(String)
290
+ uid_set = uid_set.split(",").map(&:to_i)
291
+ end
292
+ log @imap.uid_copy(uid_set, mailbox)
293
+ log @imap.uid_store(uid_set, '+FLAGS', [:Deleted])
294
+ end
295
+
296
+ # TODO mark spam
297
+
298
+ def new_message_template
299
+ headers = {'from' => "#{@name} <#{@username}>",
300
+ 'to' => nil,
301
+ 'subject' => nil
302
+ }
303
+ format_headers(headers) + "\n\n"
304
+ end
305
+
306
+ def format_headers(hash)
307
+ lines = []
308
+ hash.each_pair do |key, value|
309
+ if value.is_a?(Array)
310
+ value = value.join(", ")
311
+ end
312
+ lines << "#{key.gsub("_", '-')}: #{value}"
313
+ end
314
+ lines.join("\n")
315
+ end
316
+
317
+ def reply_template(uid, replyall=false)
318
+ log "sending reply template for #{uid}"
319
+ fetch_data = @imap.uid_fetch(uid.to_i, ["FLAGS", "ENVELOPE", "RFC822"])[0]
320
+ envelope = fetch_data.attr['ENVELOPE']
321
+ recipient = [envelope.reply_to, envelope.from].flatten.map {|x| address_to_string(x)}[0]
322
+ cc = [envelope.to, envelope.cc]
323
+ cc = cc.flatten.compact.
324
+ select {|x| @username !~ /#{x.mailbox}@#{x.host}/}.
325
+ map {|x| address_to_string(x)}.join(", ")
326
+ mail = Mail.new fetch_data.attr['RFC822']
327
+ formatter = MessageFormatter.new(mail)
328
+ headers = formatter.extract_headers
329
+ subject = headers['subject']
330
+ if subject !~ /Re: /
331
+ subject = "Re: #{subject}"
332
+ end
333
+ cc = replyall ? cc : nil
334
+ date = headers['date'].is_a?(String) ? Time.parse(headers['date']) : headers['date']
335
+ quote_header = "On #{date.strftime('%a, %b %d, %Y at %I:%M %p')}, #{recipient} wrote:\n\n"
336
+ body = quote_header + formatter.process_body.gsub(/^(?=>)/, ">").gsub(/^(?!>)/, "> ")
337
+ reply_headers = { 'from' => "#@name <#@username>", 'to' => recipient, 'cc' => cc, 'subject' => headers['subject']}
338
+ format_headers(reply_headers) + "\n\n\n" + body + signature
339
+ end
340
+
341
+ def address_to_string(x)
342
+ x.name ? "#{x.name} <#{x.mailbox}@#{x.host}>" : "#{x.mailbox}@#{x.host}"
343
+ end
344
+
345
+ def signature
346
+ return '' unless @signature
347
+ "\n\n#@signature"
348
+ end
349
+
350
+ # TODO, forward with attachments
351
+ def forward_template(uid)
352
+ original_body = lookup(uid, false, true)
353
+ new_message_template +
354
+ "\n---------- Forwarded message ----------\n" +
355
+ original_body + signature
356
+ end
357
+
358
+ def deliver(text)
359
+ # parse the text. The headers are yaml. The rest is text body.
360
+ require 'net/smtp'
361
+ mail = new_mail_from_input(text)
362
+ mail.delivery_method(*smtp_settings)
363
+ mail.deliver!
364
+ "message '#{mail.subject}' sent"
365
+ end
366
+
367
+ def save_draft(text)
368
+ mail = new_mail_from_input(text)
369
+ log mail.to_s
370
+ reconnect_if_necessary do
371
+ log "saving draft"
372
+ log @imap.append("[Gmail]/Drafts", text.gsub(/\n/, "\r\n"), [:Seen], Time.now)
373
+ end
374
+ end
375
+
376
+ def new_mail_from_input(text)
377
+ require 'mail'
378
+ mail = Mail.new
379
+ raw_headers, body = *text.split(/\n\s*\n/, 2)
380
+ # handle attachments
381
+ if (attachments = body.split(/\n\s*\n/, 2)[0]) =~ /^attach:/
382
+ # TODO
383
+ log "attachments: #{YAML::load(attachments).inspect}"
384
+ end
385
+ headers = {}
386
+ raw_headers.split("\n").each do |line|
387
+ key, value = *line.split(/:\s*/, 2)
388
+ headers[key] = value
389
+ end
390
+ log "headers: #{headers.inspect}"
391
+ log "delivering: #{headers.inspect}"
392
+ mail.from = headers['from'] || @username
393
+ mail.to = headers['to'] #.split(/,\s+/)
394
+ mail.cc = headers['cc'] #&& headers['cc'].split(/,\s+/)
395
+ mail.bcc = headers['bcc'] #&& headers['cc'].split(/,\s+/)
396
+ mail.subject = headers['subject']
397
+ mail.from ||= @username
398
+ mail.body = body
399
+ mail
400
+ end
401
+
402
+ def save_attachments(dir)
403
+ log "save_attachments #{dir}"
404
+ if !@current_message
405
+ log "missing a current message"
406
+ end
407
+ return unless dir && @current_message
408
+ attachments = @current_message.attachments
409
+ `mkdir -p #{dir}`
410
+ attachments.each do |x|
411
+ path = File.join(dir, x.filename)
412
+ log "saving #{path}"
413
+ File.open(path, 'wb') {|f| f.puts x.decoded}
414
+ end
415
+ end
416
+
417
+ def open_html_part(uid)
418
+ log "open_html_part #{uid}"
419
+ fetch_data = @imap.uid_fetch(uid.to_i, ["RFC822"])[0]
420
+ mail = Mail.new(fetch_data.attr['RFC822'])
421
+ multipart = mail.parts.detect {|part| part.multipart?}
422
+ html_part = (multipart || mail).parts.detect {|part| part.header["Content-Type"].to_s =~ /text\/html/}
423
+ outfile = 'htmlpart.html'
424
+ File.open(outfile, 'w') {|f| f.puts(html_part.decoded)}
425
+ # client should handle opening the html file
426
+ return outfile
427
+ end
428
+
429
+ def window_width=(width)
430
+ log "setting window width to #{width}"
431
+ @width = width.to_i
432
+ end
433
+
434
+ def smtp_settings
435
+ [:smtp, {:address => "smtp.gmail.com",
436
+ :port => 587,
437
+ :domain => 'gmail.com',
438
+ :user_name => @username,
439
+ :password => @password,
440
+ :authentication => 'plain',
441
+ :enable_starttls_auto => true}]
442
+ end
443
+
444
+ def log(string)
445
+ @logger.debug string
446
+ end
447
+
448
+ def handle_error(error)
449
+ log error
450
+ end
451
+
452
+ def reconnect_if_necessary(timeout = 60, &block)
453
+ # if this times out, we know the connection is stale while the user is
454
+ # trying to update
455
+ Timeout::timeout(timeout) do
456
+ block.call
457
+ end
458
+ rescue IOError, Errno::EADDRNOTAVAIL, Timeout::Error
459
+ log "error: #{$!}"
460
+ log "attempting to reconnect"
461
+ log(revive_connection)
462
+ # try just once
463
+ block.call
464
+ end
465
+
466
+ def self.start(config)
467
+ imap_client = Vmail::ImapClient.new config
468
+ imap_client.open
469
+ imap_client
470
+ end
471
+
472
+ def self.daemon(config)
473
+ $gmail = self.start(config)
474
+ puts DRb.start_service(nil, $gmail)
475
+ uri = DRb.uri
476
+ puts "starting gmail service at #{uri}"
477
+ uri
478
+ end
479
+ end
480
+ end
481
+
482
+ trap("INT") {
483
+ require 'timeout'
484
+ puts "closing imap connection"
485
+ begin
486
+ Timeout::timeout(5) do
487
+ $gmail.close
488
+ end
489
+ rescue Timeout::Error
490
+ puts "close connection attempt timed out"
491
+ end
492
+ exit
493
+ }
494
+
495
+