diff --git a/Rakefile b/Rakefile index 7ef1da8..c7d8252 100644 --- a/Rakefile +++ b/Rakefile @@ -14,6 +14,7 @@ begin gem.post_install_message = "\n\033[34mIf ruby-gmail saves you TWO hours of work, want to compensate me for, like, a half-hour?\nSupport me in making new and better gems:\033[0m \033[31;4mhttp://pledgie.com/campaigns/7087\033[0m\n\n" gem.add_dependency('shared-mime-info', '>= 0') gem.add_dependency('mail', '>= 2.2.1') + gem.add_dependency('mime', '>= 0.1') # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings end Jeweler::GemcutterTasks.new diff --git a/VERSION b/VERSION index 0ea3a94..373f8c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.2.3 \ No newline at end of file diff --git a/lib/gmail.rb b/lib/gmail.rb index 851356d..279a4f6 100644 --- a/lib/gmail.rb +++ b/lib/gmail.rb @@ -33,24 +33,101 @@ class << self # gmail.label('News') # ########################### - - def inbox - in_label('inbox') - end - def create_label(name) + @xlist_result = nil imap.create(name) end # List the available labels def labels - (imap.list("", "%") + imap.list("[Gmail]/", "%")).inject([]) { |labels,label| - label[:name].each_line { |l| labels << l }; labels } + labels = [] + prefixes = [''] + done = [] + until prefixes.empty? + prefix = prefixes.shift + done << prefix + (imap.list(prefix, "%")||[]).each { |e| + if e[:attr].include?(:Haschildren) + unless done.include?(e[:name]+"/") or e[:name].empty? + prefixes << e[:name]+"/" + end + end + unless e[:attr].include?(:Noselect) + labels << e[:name] + end + } + end + labels + end + + def imap_xlist + unless @imap.respond_to?(:xlist) + def @imap.xlist(refname, mailbox) + handler = proc do |resp| + if resp.kind_of?(Net::IMAP::UntaggedResponse) and resp.name == "XLIST" && resp.raw_data.nil? + list_resp = Net::IMAP::ResponseParser.new.instance_eval { + @str, @pos, @token = "#{resp.name} " + resp.data, 0, nil + @lex_state = Net::IMAP::ResponseParser::EXPR_BEG + list_response + } + if @responses['XLIST'].last == resp.data + @responses['XLIST'].pop + @responses['XLIST'].push(list_resp.data) + end + end + end + synchronize do + add_response_handler(handler) + send_command('XLIST', '', '*') + remove_response_handler(handler) + return @responses.delete('XLIST') + end + end + end + + @xlist_result ||= @imap.xlist('', '*') + end + + def self.gmail_label_types + [:Inbox, :Allmail, :Spam, :Trash, :Drafts, :Important, :Starred, :Sent] + end + + def gmail_label_types + self.class.gmail_label_types + end + + def normal_labels + imap_xlist.reject { |label| + label.attr.include?(:Noselect) or label.attr.any? { |flag| gmail_label_types.include?(flag) } + }.map { |label| + label.name + } + end + + def imap_xlist! + @xlist_result = nil + imap_xlist + end + + def label_of_type(type) + info = imap_xlist.find { |l| l.attr.include?(type) } + info && info.name || nil + end + + gmail_label_types.each do |label| + module_eval <<-EOL + def #{label.to_s.downcase} &block + in_label(#{label.to_s.downcase}_label, &block) + end + def #{label.to_s.downcase}_label + label_of_type(#{label.inspect}) + end + EOL end # gmail.label(name) def label(name) - mailboxes[name] ||= Mailbox.new(self, mailbox) + mailboxes[name] ||= Mailbox.new(self, name) end alias :mailbox :label @@ -119,7 +196,7 @@ def in_mailbox(mailbox, &block) end return value else - mailboxes[name] ||= Mailbox.new(self, mailbox) + mailboxes[mailbox] ||= Mailbox.new(self, mailbox) end end alias :in_label :in_mailbox diff --git a/lib/gmail/mailbox.rb b/lib/gmail/mailbox.rb index de3b760..1e8baf7 100644 --- a/lib/gmail/mailbox.rb +++ b/lib/gmail/mailbox.rb @@ -23,6 +23,39 @@ def to_s name end + FLAG_KEYWORDS = [ + ['ANSWERED', 'UNANSWERED'], + ['DELETED', 'UNDELETED'], + ['DRAFT', 'UNDRAFT'], + ['FLAGGED', 'UNFLAGGED'], + ['RECENT', 'OLD'], + ['SEEN', 'UNSEEN'] + ] + VALID_SEARCH_KEYS = %w[ + ALL + BCC + BEFORE + BODY + CC + FROM + HEADER + KEYWORD + LARGER + NEW + NOT + ON + OR + SENTBEFORE + SENTON + SENTSINCE + SINCE + SMALLER + SUBJECT + TEXT + TO + UID + UNKEYWORD + ] + FLAG_KEYWORDS.flatten # Method: emails # Args: [ :all | :unread | :read ] # Opts: {:since => Date.new} @@ -42,22 +75,130 @@ def emails(key_or_opts = :all, opts={}) else raise ArgumentError, "Couldn't make sense of arguments to #emails - should be an optional hash of options preceded by an optional read-status bit; OR simply an array of parameters to pass directly to the IMAP uid_search call." end + + fetch = opts.delete(:fetch) + if !opts.empty? # Support for several search macros # :before => Date, :on => Date, :since => Date, :from => String, :to => String - search.concat ['SINCE', opts[:after].to_imap_date] if opts[:after] - search.concat ['BEFORE', opts[:before].to_imap_date] if opts[:before] - search.concat ['ON', opts[:on].to_imap_date] if opts[:on] - search.concat ['FROM', opts[:from]] if opts[:from] - search.concat ['TO', opts[:to]] if opts[:to] + opts = opts.dup + VALID_SEARCH_KEYS.each do |keyword| + key = keyword.downcase.intern + if opts[key] + val = opts.delete(key) + case val + when Date, Time + search.concat([keyword, val.to_imap_date]) + when String + search.concat([keyword, val]) + when Numeric + search.concat([keyword, val.to_s]) + when Array + search.concat([keyword, *val]) + when TrueClass, FalseClass + # If it's a known flag keyword & val == false, + # try to invert it's meaning. + if row = FLAG_KEYWORDS.find { |row| row.include?(keyword) } + row_index = row.index(keyword) + altkey = row[ val ? row_index : 1 - row_index ] + search.push(altkey) + else + search.push(keyword) if val + end + when NilClass + next + else + search.push(keyword) # e.g. flag + end + end + end + + # API compatibility + search.concat ['SINCE', opts.delete(:after).to_imap_date] if opts[:after] + + unless opts.empty? + raise "Unrecognised keys: #{opts.keys.inspect}" + end end - # puts "Gathering #{(aliases[key] || key).inspect} messages for mailbox '#{name}'..." + list = [] + @gmail.in_mailbox(self) do - @gmail.imap.uid_search(search).collect { |uid| messages[uid] ||= Message.new(@gmail, self, uid) } + uids = @gmail.imap.uid_search(search) + list = uids.collect { |uid| messages[uid] ||= Message.new(@gmail, self, uid) } + + if fetch + missing = list.reject { |message| message.loaded? }.map { |message| message.uid } + @gmail.imap.uid_fetch(missing, ['ENVELOPE', 'RFC822']).each do |info| + message = messages[info.attr['UID']] + message.envelope = info.attr['ENVELOPE'] + message.set_body(info.attr['RFC822']) + end + else + missing = list.reject { |message| message.message_id? }.map { |message| message.uid } + @gmail.imap.uid_fetch(missing, ['ENVELOPE']).each do |info| + message = messages[info.attr['UID']] + message.envelope = info.attr['ENVELOPE'] + message.message_id = info.attr['ENVELOPE'].message_id + end + end end + + MessageList.new(@gmail, list) end + class MessageList + include Enumerable + attr_reader :list + def initialize(gmail, list) + @gmail = gmail + @list = list + end + def size + @list.size + end + def each(&block) + @list.each(&block) + end + def with_label(label) + label = label.is_a?(String) ? @gmail.label(label) : label + @gmail.in_label(label) do |mbox| + + # Search for message ids in named folder + search = [] + @list.each_with_index do |m, index| + search.unshift "OR" unless index.zero?#.empty? + search << "HEADER" << "Message-ID" << m.message_id + end + uids = @gmail.imap.uid_search(search) + + # Fetch envelopes for uids + message_ids = [] + + missing_uids = uids.collect { |uid| + mbox.messages[uid] ||= Message.new(@gmail, mbox, uid) + }.reject { |message| + if message.loaded? or message.message_id? + message_ids << message.message_id + true + else + false + end + }.map { |message| + message.uid + } + + message_ids += @gmail.imap.uid_fetch(missing_uids, ['ENVELOPE']).map do |info| + message = mbox.messages[info.attr['UID']] + message.envelope ||= info.attr['ENVELOPE'] + info.attr['ENVELOPE'].message_id + end + + MessageList.new(@gmail, @list.select { |m| message_ids.include?(m.message_id) }) + end + end + end + # This is a convenience method that really probably shouldn't need to exist, but it does make code more readable # if seriously all you want is the count of messages. def count(*args) diff --git a/lib/gmail/message.rb b/lib/gmail/message.rb index d23ceeb..0a7214d 100644 --- a/lib/gmail/message.rb +++ b/lib/gmail/message.rb @@ -1,4 +1,3 @@ -require 'mime/message' class Gmail class Message def initialize(gmail, mailbox, uid) @@ -29,6 +28,65 @@ def unflag(flg) end ? true : false end + attr_writer :message_id + attr_writer :envelope + def message_id? + !! (@envelope || @message_id || @message) + end + def message_id + @message_id ||= @envelope ? @envelope.message_id : self.header['Message-ID'].value + end + def envelope + @envelope ||= @gmail.in_mailbox(@mailbox) { @gmail.imap.uid_fetch(uid, "ENVELOPE")[0].attr["ENVELOPE"] } + end + def subject + @envelope ? @envelope.subject : self.header['Subject'].value + end + def from + @envelope ? @envelope.from : self.header['From'].value + end + def to + @envelope ? @envelope.to : self.header['To'].value + end + + def has_label?(label) + return true if @mailbox == @gmail.mailbox(label) + @gmail.in_mailbox(@gmail.mailbox(label)) do |mailbox| + search = ['HEADER', 'Message-ID', self.message_id] + res = @gmail.imap.uid_search(search) + if res && !res.empty? + return true + end + end + false + end + + def archived? + ! has_label?(@gmail.inbox_label) + end + + def starred? + has_label?(@gmail.starred_label) + end + + def sent? + has_label?(@gmail.sent_label) + end + + def important? + has_label?(@gmail.important_label) + end + + def labels + mbox = @mailbox + list = [mbox.name] + @gmail.normal_labels.each do |label| + next if mbox.name == label + list << label if has_label?(label) + end + list + end + # Gmail Operations def mark(flag) case flag @@ -39,7 +97,7 @@ def mark(flag) when :deleted flag(:Deleted) when :spam - move_to('[Gmail]/Spam') + move_to(@gmail.spam_label) end ? true : false end @@ -70,23 +128,48 @@ def label!(name) end end - # We're not sure of any 'labels' except the 'mailbox' we're in at the moment. - # Research whether we can find flags that tell which other labels this email is a part of. - # def remove_label(name) - # end + def remove_label(label) + return false if label.downcase == @gmail.allmail_label.downcase + + return delete! if label.downcase == @mailbox.name.downcase + + @gmail.in_mailbox(@gmail.label(label)) do |mailbox| + message = mailbox.emails(['HEADER', 'Message-ID', self.message_id]).first + if message + message.delete! + else + # Doesn't have label in the first place? + end + end + end def move_to(name) label(name) && delete! end + # Archive, in the gmail sense, means remove label Inbox, + # rather than simply remove current label def archive! - move_to('[Gmail]/All Mail') + remove_label(@gmail.inbox_label) end - private + def save_attachments_to(path=nil) + attachments.each {|a| a.save_to_file(path) } + end + def loaded? + !! @message + end + + def set_body(body) + require 'mail' + @message = Mail.new(body) + end + + private # Parsed MIME message object def message + return @message if @message require 'mail' _body = @gmail.in_mailbox(@mailbox) { @gmail.imap.uid_fetch(uid, "RFC822")[0].attr["RFC822"] } @message ||= Mail.new(_body) diff --git a/ruby-gmail.gemspec b/ruby-gmail.gemspec index 2a88686..a428319 100644 --- a/ruby-gmail.gemspec +++ b/ruby-gmail.gemspec @@ -5,11 +5,11 @@ Gem::Specification.new do |s| s.name = %q{ruby-gmail} - s.version = "0.2.0" + s.version = "0.2.2" s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= s.authors = ["BehindLogic"] - s.date = %q{2010-05-14} + s.date = %q{2010-11-07} s.description = %q{A Rubyesque interface to Gmail, with all the tools you'll need. Search, read and send multipart emails; archive, mark as read/unread, delete emails; and manage labels.} s.email = %q{gems@behindlogic.com} s.extra_rdoc_files = [ @@ -39,7 +39,7 @@ Support me in making new and better gems: http://pledgie.com/campaign } s.rdoc_options = ["--charset=UTF-8"] s.require_paths = ["lib"] - s.rubygems_version = %q{1.3.6} + s.rubygems_version = %q{1.3.7} s.summary = %q{A Rubyesque interface to Gmail, with all the tools you'll need.} s.test_files = [ "test/test_gmail.rb", @@ -50,16 +50,19 @@ Support me in making new and better gems: http://pledgie.com/campaign current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION s.specification_version = 3 - if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then + if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then s.add_runtime_dependency(%q, [">= 0"]) s.add_runtime_dependency(%q, [">= 2.2.1"]) + s.add_runtime_dependency(%q, [">= 0.1"]) else s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 2.2.1"]) + s.add_dependency(%q, [">= 0.1"]) end else s.add_dependency(%q, [">= 0"]) s.add_dependency(%q, [">= 2.2.1"]) + s.add_dependency(%q, [">= 0.1"]) end end