luggage 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.
@@ -0,0 +1,151 @@
1
+ module Luggage
2
+ class Message
3
+ class MessageNotFoundError < ArgumentError; end
4
+ class DuplicateMessageError < StandardError; end
5
+
6
+ attr_accessor :flags
7
+ attr_reader :connection, :mailbox, :template, :date, :message_id
8
+
9
+ # Creates a local Message instance
10
+ #
11
+ # `connection` should be an authenticated Imap connection
12
+ # `mailbox` should be either a Mailbox or a string describing a remote mailbox
13
+ # `args` will be passed to Message::new. Keys used by Message::new will be set, any
14
+ # other keys will be delegated to Mail::Message.
15
+ #
16
+ # Example Usage:
17
+ # Message.new_local(c, 'INBOX', :template => 'base.eml', :date => 4.days.ago)
18
+ # Message.new_local(c, m, :subject => "VIAGRA ROCKS!", :body => "<insert phishing here>", :cc => "yourmom@gmail.com")
19
+ #
20
+ def self.new_local(connection, mailbox, args = {}, &block)
21
+ message = new(connection, mailbox, args)
22
+ message.instance_eval do
23
+ @mail = @template ? Mail.read(@template) : Mail.new
24
+ args.each do |key, value|
25
+ mail[key] = value if mail.respond_to?(key)
26
+ end
27
+ mail[:message_id] = message_id
28
+ end
29
+
30
+ message.instance_eval &block if block_given?
31
+ message
32
+ end
33
+
34
+ # Creates a Message instance
35
+ #
36
+ # `connection` should be an authenticated Imap connection
37
+ # `mailbox` should be either a Mailbox or a string describing a remote mailbox
38
+ # `args[:date]` when this message is appended to the remote server, this is the date which will be used
39
+ # `args[:template]` use this file as the initial raw email content.
40
+ # `args[:message_id]` use this as for the Message-ID header. This header is used to identify messages across requests
41
+ #
42
+ def initialize(connection, mailbox, args = {}, &block)
43
+ raise ArgumentError, "Net::IMAP connection required" unless connection.kind_of?(Net::IMAP)
44
+
45
+ @connection = connection
46
+ @mailbox = mailbox.kind_of?(Mailbox) ? mailbox : Mailbox.new(connection, mailbox)
47
+ @flags = []
48
+ @date = args[:date] || Time.now
49
+ @template = args[:template]
50
+ @message_id = args[:message_id] || "<#{UUIDTools::UUID.random_create}@test.oib.com>"
51
+
52
+ raise ArgumentError, "mailbox requried" unless @mailbox.present?
53
+
54
+ instance_eval &block if block_given?
55
+ end
56
+
57
+ # Formatted to save to file
58
+ #
59
+ # Mail::Message.new( message.to_s ).raw_source = message.to_s
60
+ #
61
+ def to_s
62
+ mail.encoded
63
+ end
64
+
65
+ # Fetch this message from the server and update all its attributes
66
+ #
67
+ def reload
68
+ fields = fetch_fields
69
+ @mail = Mail.new(fields["BODY[]"])
70
+ @flags = fields["FLAGS"]
71
+ @date = Time.parse(fields["INTERNALDATE"])
72
+ self
73
+ end
74
+
75
+ # Append this message to the remote mailbox
76
+ #
77
+ def save!
78
+ mailbox.select!
79
+ connection.append(mailbox.name, raw_message, flags.map {|f| f.to_sym.upcase}, date)
80
+ end
81
+
82
+ # Add the 'Deleted' flag to this message on the remote server
83
+ #
84
+ def delete!
85
+ mailbox.select!
86
+ connection.uid_store([uid], "+FLAGS", [:Deleted])
87
+ @mail = nil
88
+ end
89
+
90
+ # Returns true if a message with the same message_id exists in the remote mailbox
91
+ #
92
+ def exists?
93
+ mailbox.select!
94
+ connection.uid_search("HEADER Message-ID #{message_id}").present?
95
+ end
96
+
97
+ # Proxy all other methods to this instance's Mail::Message
98
+ #
99
+ def method_missing(meth, *args, &block)
100
+ if mail.respond_to?(meth)
101
+ mail.send(meth, *args, &block)
102
+ else
103
+ super
104
+ end
105
+ end
106
+
107
+ def inspect
108
+ "#<Luggage::Message server: \"#{host}\", mailbox: \"#{mailbox.name}\", message_id: \"#{message_id}\">"
109
+ end
110
+
111
+ def host
112
+ connection.instance_variable_get(:@host)
113
+ end
114
+
115
+ private
116
+
117
+ # Formatted to upload to IMAP server
118
+ #
119
+ def raw_message
120
+ mail.to_s
121
+ end
122
+
123
+ def mail
124
+ reload unless @mail.present?
125
+ @mail
126
+ end
127
+
128
+ def uid
129
+ unless @uid.present?
130
+ mailbox.select!
131
+ @uid = fetch_uid
132
+ end
133
+ @uid
134
+ end
135
+
136
+ def fetch_uid
137
+ response = connection.uid_search("HEADER Message-ID #{message_id}")
138
+ raise MessageNotFoundError if response.empty?
139
+ raise DuplicateMessageError if response.length > 1
140
+ response.first
141
+ end
142
+
143
+ def fetch_fields
144
+ response = connection.uid_fetch([uid], ["FLAGS", "INTERNALDATE", "BODY.PEEK[]"])
145
+ raise MessageNotFoundError if response.empty?
146
+ raise DuplicateMessageError if response.length > 1
147
+ response.first[:attr]
148
+ end
149
+ end
150
+ end
151
+
@@ -0,0 +1,3 @@
1
+ module Luggage
2
+ VERSION = "1.0.0"
3
+ end
data/lib/luggage.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'net/imap'
2
+ require 'mail'
3
+ require 'uuidtools'
4
+
5
+ require 'luggage/factory'
6
+ require 'luggage/mailbox'
7
+ require 'luggage/mailbox_array'
8
+ require 'luggage/mailbox_query_builder'
9
+ require 'luggage/message'
10
+ require 'luggage/version'
11
+
12
+ module Luggage
13
+ def self.new(*args, &block)
14
+ Factory.new(*args, &block)
15
+ end
16
+ end
data/luggage.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ require File.expand_path('../lib/luggage/version', __FILE__)
4
+
5
+ Gem::Specification.new do |gem|
6
+ gem.name = "luggage"
7
+ gem.version = Luggage::VERSION
8
+ gem.summary = %q{Net::IMAP DSL}
9
+ gem.description = %q{Easily interact with IMAP servers}
10
+ gem.license = "MIT"
11
+ gem.authors = ["Ryan Michael", "Eric Pinzur"]
12
+ gem.email = "ryanmichael@otherinbox.com"
13
+ gem.homepage = "https://github.com/otherinbox/luggage"
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ['lib']
19
+
20
+ gem.add_dependency 'mail', '~> 2.5.0'
21
+ gem.add_dependency 'uuidtools', '~> 2.1.3'
22
+
23
+ gem.add_development_dependency 'bundler', '~> 1.0'
24
+ gem.add_development_dependency 'rake', '~> 0.8'
25
+ gem.add_development_dependency 'rdoc', '~> 3.0'
26
+ gem.add_development_dependency 'rspec', '~> 2.4'
27
+ gem.add_development_dependency 'activesupport', '~> 3.2.9'
28
+ gem.add_development_dependency 'fuubar', '~> 1.1.0'
29
+ gem.add_development_dependency 'pry', '~> 0.9.10'
30
+ end
@@ -0,0 +1,83 @@
1
+ require 'spec_helper'
2
+
3
+ describe Luggage::Factory do
4
+ include_context "factories"
5
+
6
+ describe "::new" do
7
+ it "accepts :connection" do
8
+ expect(Luggage.new(:connection => connection).connection).to eq(connection)
9
+ end
10
+
11
+ it "accepts :server, :authenticate and creates a connection" do
12
+ Net::IMAP.any_instance.stub(:authenticate).and_return(true)
13
+ expect(Luggage.new(:server => :foo, :authenticate => "LOGIN username password").connection).to be_a(Net::IMAP)
14
+ end
15
+
16
+ it "accepts :server, :xoauth and creates a connection" do
17
+ Net::IMAP.any_instance.stub(:send_command).and_return(true)
18
+ expect(Luggage.new(:server => :foo, :xoauth => "token").connection).to be_a(Net::IMAP)
19
+ end
20
+
21
+ it "requires a way to build a connection" do
22
+ expect { Luggage.new }.to raise_error(ArgumentError)
23
+ end
24
+ end
25
+
26
+ describe '#message' do
27
+ it "returns a Luggage::Message" do
28
+ expect(factory.message(:mailbox)).to be_a(Luggage::Message)
29
+ end
30
+
31
+ it "passes arguments to initialize" do
32
+ Luggage::Message.should_receive(:new).with(connection, "mailbox_name", :key => :value).and_return( double('Message').as_null_object )
33
+ factory.message("mailbox_name", :key => :value)
34
+ end
35
+ end
36
+
37
+ describe "#mailboxes" do
38
+ before(:each) do
39
+ connection.stub(:list).
40
+ and_return([
41
+ OpenStruct.new(:name => "Mailbox_1"),
42
+ OpenStruct.new(:name => "Mailbox_2"),
43
+ OpenStruct.new(:name => "Mailbox_3"),
44
+ OpenStruct.new(:name => "Mailbox_4") ])
45
+ end
46
+
47
+ context "with no arguments" do
48
+ it "returns an array of mailboxes defined on the remote server" do
49
+ expect(factory.mailboxes.map(&:name)).to eq(["Mailbox_1", "Mailbox_2", "Mailbox_3", "Mailbox_4"])
50
+ end
51
+ end
52
+
53
+ context "with a string argument (function-call syntax)" do
54
+ it "returns a mailbox with the passed name" do
55
+ expect(factory.mailboxes("foo").name).to eq("foo")
56
+ end
57
+ end
58
+
59
+ context "with an iteger (function-call syntax)" do
60
+ it "returns the nth mailbox on the remote server" do
61
+ expect(factory.mailboxes(1).name).to eq("Mailbox_2")
62
+ end
63
+ end
64
+
65
+ context "with a string argument (hash-index syntax)" do
66
+ it "returns a mailbox with the passed name" do
67
+ expect(factory.mailboxes["foo"].name).to eq("foo")
68
+ end
69
+ end
70
+
71
+ context "with an integer (array-index syntax)" do
72
+ it "returns the nth mailbox on the remote server" do
73
+ expect(factory.mailboxes[1].name).to eq("Mailbox_2")
74
+ end
75
+ end
76
+
77
+ context "with a range (array-slice syntax)" do
78
+ it "returns a subset array of mailboxes defined on the remote server" do
79
+ expect(factory.mailboxes[1..2].map(&:name)).to eq(["Mailbox_2", "Mailbox_3"])
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,48 @@
1
+ require 'spec_helper'
2
+
3
+ describe Luggage::MailboxQueryBuilder do
4
+ include_context "factories"
5
+
6
+ describe "::new" do
7
+ it "sets the mailbox" do
8
+ expect(Luggage::MailboxQueryBuilder.new(mailbox).mailbox).to eq(mailbox)
9
+ end
10
+
11
+ it "requires a mailbox" do
12
+ expect { Luggage::MailboxQueryBuilder.new(:not_a_mailbox) }.to raise_error(ArgumentError)
13
+ end
14
+
15
+ it "sets the connection" do
16
+ expect(Luggage::MailboxQueryBuilder.new(mailbox).connection).to eq(connection)
17
+ end
18
+ end
19
+
20
+ describe "#each" do
21
+ it "yields to each member of #messages" do
22
+ block = Proc.new {}
23
+ query_builder.stub(:message_ids).and_return(['<one@example.com>', '<two@example.com>'])
24
+ query_builder.messages.should_receive(:each) #.with(block)
25
+
26
+ query_builder.each(&block)
27
+ end
28
+ end
29
+
30
+ describe "::where" do
31
+ it "returns self" do
32
+ expect(query_builder.where(:foo => :bar)).to be_a(Luggage::MailboxQueryBuilder)
33
+ end
34
+
35
+ it "appends to the query" do
36
+ expect(query_builder.where(:foo => :bar).where(:baz => :qux).query).to eq([:foo, :bar, :baz, :qux])
37
+ end
38
+ end
39
+
40
+ describe "::[]" do
41
+ it "returns messages[]" do
42
+ query_builder.stub(:message_ids).and_return(['<one@example.com>', '<two@example.com>'])
43
+ query_builder.messages.should_receive(:[]).with((1..2))
44
+
45
+ query_builder[1..2]
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,135 @@
1
+ require 'spec_helper'
2
+
3
+ describe Luggage::Mailbox do
4
+ include_context "factories"
5
+
6
+ describe "::new" do
7
+ it "executes a passed block" do
8
+ m = Luggage::Mailbox.new(connection, :mailbox) do
9
+ @name = "woot"
10
+ end
11
+ expect(m.name).to eq("woot")
12
+ end
13
+
14
+ it "sets the connection" do
15
+ expect(Luggage::Mailbox.new(connection, :mailbox).connection).to eq(connection)
16
+ end
17
+
18
+ it "requires a connection" do
19
+ expect { Luggage::Mailbox.new(:foo, :mailbox).connection }.to raise_error(ArgumentError)
20
+ end
21
+
22
+ it "sets the name" do
23
+ expect(Luggage::Mailbox.new(connection, :mailbox).name).to eq(:mailbox)
24
+ end
25
+ end
26
+
27
+ describe "message" do
28
+ it "executes a passed block" do
29
+ m = mailbox.message do
30
+ subject("new subject")
31
+ end
32
+ expect(m.subject).to eq("new subject")
33
+ end
34
+
35
+
36
+ it "returns an Luggage::Message" do
37
+ expect(mailbox.message).to be_a(Luggage::Message)
38
+ end
39
+
40
+ it "instantiates with expected arguments" do
41
+ message = Luggage::Message.new(connection, mailbox, :foo => :bar)
42
+ Luggage::Message.should_receive(:new).with(connection, mailbox, :foo => :bar).and_return(message)
43
+
44
+ mailbox.message(:foo => :bar)
45
+ end
46
+ end
47
+
48
+ describe "all" do
49
+ it "returns a QueryBuilder" do
50
+ expect(mailbox.all).to be_a(Luggage::MailboxQueryBuilder)
51
+ end
52
+ end
53
+
54
+ describe "each" do
55
+ it "delegates to QueryBuilder instance" do
56
+ block = Proc.new {}
57
+ Luggage::MailboxQueryBuilder.any_instance.should_receive(:each).with(&block)
58
+
59
+ mailbox.each(&block)
60
+ end
61
+ end
62
+
63
+ describe "first" do
64
+ it "delegates to QueryBuilder instance" do
65
+ Luggage::MailboxQueryBuilder.any_instance.should_receive(:first)
66
+
67
+ mailbox.first
68
+ end
69
+ end
70
+
71
+ describe "where" do
72
+ it "delegates to QueryBuilder instance" do
73
+ Luggage::MailboxQueryBuilder.any_instance.should_receive(:where).with(:foo => :bar)
74
+
75
+ mailbox.where(:foo => :bar)
76
+ end
77
+ end
78
+
79
+ describe "select!" do
80
+ it "selects the mailbox" do
81
+ connection.should_receive(:select).with(:mailbox)
82
+
83
+ mailbox.select!
84
+ end
85
+ end
86
+
87
+ describe "save!" do
88
+ it "creates the mailbox if it doesn't exist" do
89
+ connection.should_receive(:create).with(:mailbox)
90
+
91
+ mailbox.save!
92
+ end
93
+
94
+ it "doesn't create the mailbox if the mailbox exists" do
95
+ mailbox.stub(:exists?).and_return(true)
96
+ connection.should_not_receive(:create)
97
+
98
+ mailbox.save!
99
+ end
100
+ end
101
+
102
+ describe "expunge!" do
103
+ it "selects the mailbox" do
104
+ connection.should_receive(:select).with(:mailbox)
105
+
106
+ mailbox.expunge!
107
+ end
108
+
109
+ it "expunges the mailbox" do
110
+ connection.should_receive(:expunge)
111
+
112
+ mailbox.expunge!
113
+ end
114
+ end
115
+
116
+ describe "delete!" do
117
+ it "deletes the mailbox" do
118
+ connection.should_receive(:delete)
119
+
120
+ mailbox.delete!
121
+ end
122
+ end
123
+
124
+ describe "exists?" do
125
+ it "returns true if list includes the mailbox name" do
126
+ connection.stub(:list).with("", :mailbox).and_return([:results])
127
+
128
+ expect(mailbox.exists?).to be_true
129
+ end
130
+
131
+ it "returns false if the list doesn't include the mailbox name" do
132
+ expect(mailbox.exists?).to be_false
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,181 @@
1
+ require 'spec_helper'
2
+
3
+ describe Luggage::Message do
4
+ include_context "factories"
5
+
6
+ describe "::new_local" do
7
+ it "executes a passed block" do
8
+ m = Luggage::Message.new_local(connection, "Inbox") do
9
+ subject("new subject")
10
+ end
11
+ expect(m.subject).to eq("new subject")
12
+ end
13
+
14
+ it "returns an Luggage::Message" do
15
+ expect(Luggage::Message.new_local(connection, "Inbox")).to be_a(Luggage::Message)
16
+ end
17
+
18
+ it "raises ArgumentError if Luggage::Mailbox isn't passed" do
19
+ expect{ Luggage::Message.new_local() }.to raise_error(ArgumentError)
20
+ end
21
+
22
+ [:subject, :body, :to].each do |method|
23
+ context "with #{method} passed as argument" do
24
+ it "sets #{method} on Mail object" do
25
+ message = Luggage::Message.new_local(connection, :mailbox, method => "string")
26
+ expect(message.to_s).to match(/string/)
27
+ end
28
+ end
29
+ end
30
+
31
+ context "without template" do
32
+ it "instatiates a Mail object" do
33
+ mail = Mail.new
34
+ Mail.should_receive(:new).and_return(mail)
35
+ Luggage::Message.new_local(connection, :mailbox)
36
+ end
37
+ end
38
+
39
+ context "with a template" do
40
+ it "instatiates a Mail object" do
41
+ Mail.should_receive(:read).and_return({})
42
+
43
+ Luggage::Message.new_local(connection, :mailbox, :template =>"base")
44
+ end
45
+ end
46
+ end
47
+
48
+ describe "::new" do
49
+ it "executes a passed block" do
50
+ m = Luggage::Message.new(connection, "Inbox") do
51
+ subject("new subject")
52
+ end
53
+ expect(m.subject).to eq("new subject")
54
+ end
55
+
56
+ it "sets connection" do
57
+ expect(Luggage::Message.new(connection, :mailbox).connection).to eq(connection)
58
+ end
59
+
60
+ it "requires a connection" do
61
+ expect { Luggage::Message.new(:not_a_connection, :mailbox).connection }.to raise_error(ArgumentError)
62
+ end
63
+
64
+ it "sets mailbox if Mailbox passed" do
65
+ expect(Luggage::Message.new(connection, mailbox).mailbox).to eq(mailbox)
66
+ end
67
+
68
+ it "instantiates new mailbox if string passed" do
69
+ expect(Luggage::Message.new(connection, :mailbox).mailbox).to be_a(Luggage::Mailbox)
70
+ end
71
+
72
+ it "sets date if passed" do
73
+ date = 2.days.ago
74
+ expect(Luggage::Message.new(connection, :mailbox, :date => date).date).to eq(date)
75
+ end
76
+
77
+ it "sets date to now if not passed" do
78
+ expect(Luggage::Message.new(connection, :mailbox).date).to be_a(Time)
79
+ end
80
+
81
+ it "sets message_id if passed" do
82
+ message_id = "<foo@example.com>"
83
+ expect(Luggage::Message.new(connection, :mailbox, :message_id => message_id).message_id).to eq(message_id)
84
+ end
85
+
86
+ it "creates message_id if not passed" do
87
+ expect(Luggage::Message.new(connection, :mailbox).message_id).to match(/<\S*@\S*>/)
88
+ end
89
+ end
90
+
91
+ describe "#reload" do
92
+ it "selects mailbox" do
93
+ connection.should_receive(:select).with("Inbox")
94
+
95
+ message.reload
96
+ end
97
+
98
+ it "fetches raw email" do
99
+ connection.should_receive(:uid_fetch).
100
+ with([1], ["FLAGS", "INTERNALDATE", "BODY.PEEK[]"]).
101
+ and_return( [{:attr => {"BODY[]" => "raw_body", "FLAGS" => [], "INTERNALDATE" => 1.day.ago.to_s}}] )
102
+
103
+ message.reload
104
+ end
105
+
106
+ it "fetches flags" do
107
+ connection.should_receive(:uid_fetch).
108
+ with([1], ["FLAGS", "INTERNALDATE", "BODY.PEEK[]"]).
109
+ and_return( [{:attr => {"BODY[]" => "raw_body", "FLAGS" => [], "INTERNALDATE" => 1.day.ago.to_s}}] )
110
+
111
+ message.reload
112
+ end
113
+
114
+ it "creates new Mail instance" do
115
+ message # To instantiate the first one...
116
+
117
+ Mail.should_receive(:new).with("raw_body")
118
+
119
+ message.reload
120
+ end
121
+ end
122
+
123
+ describe "#save!" do
124
+ it "selects mailbox" do
125
+ connection.should_receive(:select).with("Inbox")
126
+
127
+ message.save!
128
+ end
129
+
130
+ it "appends message to mailbox" do
131
+ message_date = 2.days.ago
132
+ message.stub(:raw_message).and_return("Random Content")
133
+ message.stub(:flags).and_return([:Seen])
134
+ message.stub(:date).and_return(message_date)
135
+
136
+ connection.should_receive(:append).with("Inbox", "Random Content", [:SEEN], message_date)
137
+ message.save!
138
+ end
139
+ end
140
+
141
+ describe "#to_s" do
142
+ it "returns a string" do
143
+ expect(message.to_s).to be_a(String)
144
+ end
145
+ end
146
+
147
+
148
+ describe "#delete!" do
149
+ it "selects mailbox" do
150
+ connection.should_receive(:select).with("Inbox")
151
+
152
+ message.delete!
153
+ end
154
+
155
+ it "sets Deleted flag" do
156
+ connection.should_receive(:uid_store).with([1], "+FLAGS", [:Deleted])
157
+
158
+ message.delete!
159
+ end
160
+
161
+ it "resets cached mail instance" do
162
+ message.delete!
163
+ expect(message.instance_variable_get(:@mail)).to be_nil
164
+ end
165
+ end
166
+
167
+ describe "#exists?" do
168
+ it "selects mailbox" do
169
+ connection.should_receive(:select).with("Inbox")
170
+
171
+ message.exists?
172
+ end
173
+
174
+ it "searches for message with message_id" do
175
+ message.stub(:message_id).and_return("<foo@example.com>")
176
+ connection.should_receive(:uid_search).with("HEADER Message-ID <foo@example.com>").and_return([1])
177
+
178
+ message.exists?
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,16 @@
1
+ require 'spec_helper'
2
+
3
+ describe Luggage do
4
+ it "should have a VERSION constant" do
5
+ subject.const_get('VERSION').should_not be_empty
6
+ end
7
+
8
+ include_context "factories"
9
+
10
+ describe "::new" do
11
+ it "proxies Luggage::Factory" do
12
+ Luggage::Factory.should_receive(:new).with(:server => :foo, :authenticate => :bar)
13
+ Luggage.new(:server => :foo, :authenticate => :bar)
14
+ end
15
+ end
16
+ end
data/spec/net_imap.rb ADDED
@@ -0,0 +1,15 @@
1
+ # mock Net::IMAP
2
+ class Net::IMAP
3
+ def initialize(*args, &block)
4
+ end
5
+
6
+ def login(user, password)
7
+ end
8
+
9
+ def disconnect
10
+ end
11
+
12
+ def disconnected?
13
+ end
14
+ end
15
+