inbox-sync 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # InboxSync
2
2
 
3
- Move messages from one inbox to another. Useful when server-side email forwarding is not an option. (TODO) Can apply filters to messages as they are being moved. Run on-demand, on a schedule, or as a daemon.
3
+ Move messages from one inbox to another. Useful when server-side email forwarding is not an option. Can apply filters to messages as they are being moved. Run on-demand, on a schedule, or as a daemon.
4
4
 
5
5
  ## Installation
6
6
 
@@ -20,7 +20,7 @@ Or install it yourself as:
20
20
 
21
21
  InboxSync uses IMAP to query a source inbox, process its messages, append them to a destination inbox, and archive them on the source. It logs each step in the process and will send notification emails when something goes wrong.
22
22
 
23
- (TODO) InboxSync provides a framework for defining destination filters for post-sync mail processing (ie moving/archiving, copying/labeling, deletion, etc).
23
+ InboxSync provides a framework for defining destination filters for post-sync mail processing (ie moving/archiving, copying/labeling, deletion, etc).
24
24
 
25
25
  InboxSync provides a basic ruby runner class to handle polling the source on an interval and running the configured sync(s). You can call it in any number of ways: in a script, from a cron, as a daemon, or as part of a larger system.
26
26
 
@@ -158,7 +158,71 @@ The runner traps `SIGINT` and `SIGQUIT` and will shutdown nicely once any in-pro
158
158
 
159
159
  ## Filter Framework
160
160
 
161
- TODO
161
+ You can configure filters for your syncs. Filters are applied to destination messages after they have been appended to the inbox.
162
+
163
+ ```ruby
164
+ sync = InboxSync.new.configure do
165
+
166
+ # conditions to match on
167
+ filter(:subject => contains('hi there')) do
168
+ # actions to perform
169
+ copy_to 'Some-Folder'
170
+ end
171
+
172
+ end
173
+ ```
174
+
175
+ ### Conditions
176
+
177
+ The first step in defining a filter is specifying the match conditions. You can filter on any attribute of the message (`Mail::Message`). The filter conditions are specified as a hash, where the keys are the attributes to match on and the values are what to match with.
178
+
179
+ The default comparison is equals (`==`). You have a few helpers for other comparisons at your disposal as well:
180
+
181
+ * `contains`: converts to `/.*#{value}.*/` and matches. aliased as `like` and `includes`.
182
+ * `starts_with`: converts your value to `/\A#{value}.*/` and matches. aliased as `sw`.
183
+ * `ends_with`: converts your value to `/.*#{value}\Z/` and matches. aliased as `ew`.
184
+ * pass a custom regex: it will be matched
185
+
186
+ ```ruby
187
+ sync = InboxSync.new.configure do
188
+
189
+ filter(:subject => 'hi there you') { ... }
190
+ filter(:subject => contains('there')) { ... }
191
+ filter(:subject => starts_with('hi') { ... }
192
+ filter(:subject => ends_with('you')) { ... }
193
+ filter(:subject => /\Ahi\s+.+\s+you\Z/ { ... }
194
+
195
+ end
196
+ ```
197
+
198
+ ### Actions
199
+
200
+ The second step in defining a filter is what to do with a message if it matches. InboxSync provides a set of actions that can be performed on a message.
201
+
202
+ * `copy_to`: copies the message to a given folder. will create the folder if necessary. aliased as `label`.
203
+ * `move_to`: moves the message to a given folder. will create the folder if necessary. aliased as `archive_to`.
204
+ * `mark_read`: marks the message as read (flag :Seen)
205
+ * `delete`: deletes the message (flag :Deleted)
206
+ * `flag`: apply a custom flag
207
+
208
+ Actions are specified using a block. If a message matches the filter conditions, the filters actions will be applied to the message on the destination. In the case multiplie filters match the message, actions are aggregated and applied once after all filters have been processed.
209
+
210
+ ```ruby
211
+ sync = InboxSync.new.configure do
212
+
213
+ filter(...) { copy_to 'Something' }
214
+ filter(...) { move_to 'Somewhere' }
215
+ filter(...) { mark_read; archive }
216
+
217
+ end
218
+ ```
219
+
220
+ Actions are applied according to precedence rules. They go something like this:
221
+
222
+ * flags first - they will carry over as messages are copied/moved.
223
+ * copies/moves next - moves are just a macro for copy-then-delete
224
+
225
+ This order ensures the message is available for all actions needed.
162
226
 
163
227
  ## Error Handling
164
228
 
@@ -0,0 +1,41 @@
1
+ module InboxSync
2
+ class Config; end
3
+
4
+ class Config::Filter
5
+
6
+ attr_reader :conditions, :actions
7
+
8
+ def initialize(conditions, &actions)
9
+ @actions = actions
10
+
11
+ # make sure all match conditions are regexps
12
+ @conditions = conditions.keys.inject({}) do |processed, key|
13
+ val = conditions[key]
14
+ processed[key] = val.kind_of?(Regexp) ? val : /#{val.to_s}/
15
+ processed
16
+ end
17
+ end
18
+
19
+ def match?(message)
20
+ @conditions.keys.inject(true) do |result, key|
21
+ result && value_matches?(message.send(key), @conditions[key])
22
+ end
23
+ end
24
+
25
+ protected
26
+
27
+ def value_matches?(value, regexp)
28
+ if value.respond_to?(:inject)
29
+ # this is a collection, match if any one item matches
30
+ value.inject(false) do |result, item|
31
+ result || !!(item.to_s =~ regexp)
32
+ end
33
+ else
34
+ # single value match
35
+ !!(value.to_s =~ regexp)
36
+ end
37
+ end
38
+
39
+ end
40
+
41
+ end
@@ -2,6 +2,7 @@ require 'logger'
2
2
  require 'ns-options'
3
3
  require 'inbox-sync/config/imap_config'
4
4
  require 'inbox-sync/config/smtp_config'
5
+ require 'inbox-sync/config/filter'
5
6
 
6
7
  module InboxSync
7
8
 
@@ -14,6 +15,11 @@ module InboxSync
14
15
 
15
16
  opt :archive_folder, :default => 'Archived'
16
17
  opt :logger, Logger, :required => true, :default => STDOUT
18
+ opt :filters, :default => [], :required => true
19
+
20
+ def filter(*args, &block)
21
+ filters << Filter.new(*args, &block)
22
+ end
17
23
 
18
24
  def validate!
19
25
  if !required_set?
@@ -25,6 +31,18 @@ module InboxSync
25
31
  notify.validate!
26
32
  end
27
33
 
34
+ protected
35
+
36
+ def contains(value); /.*#{value}.*/; end
37
+ def starts_with(value); /\A#{value}.*/; end
38
+ def ends_with(value); /.*#{value}\Z/; end
39
+
40
+ alias_method :like, :contains
41
+ alias_method :includes, :contains
42
+ alias_method :inc, :includes
43
+ alias_method :sw, :starts_with
44
+ alias_method :ew, :ends_with
45
+
28
46
  end
29
47
 
30
48
  end
@@ -0,0 +1,86 @@
1
+ module InboxSync
2
+
3
+ class FilterActions
4
+
5
+ attr_reader :message
6
+
7
+ def initialize(message)
8
+ @message = message
9
+ @copies = @flags = []
10
+ end
11
+
12
+ def copies; @copies.uniq; end
13
+ def flags; @flags.uniq; end
14
+
15
+ def copy_to(*folders)
16
+ @copies += args_collection(folders)
17
+ end
18
+ alias_method :label, :copy_to
19
+
20
+ def move_to(*folders)
21
+ copy_to *folders
22
+ delete
23
+ end
24
+ alias_method :archive_to, :move_to
25
+
26
+ def flag(*flags)
27
+ @flags += args_collection(flags).map{|f| f.to_sym}
28
+ end
29
+
30
+ def mark_read
31
+ flag(:Seen)
32
+ end
33
+
34
+ def delete
35
+ flag(:Deleted)
36
+ end
37
+
38
+ def match!(filters)
39
+ filters.each do |filter|
40
+ instance_eval(&filter.actions) if filter.match?(@message)
41
+ end
42
+ end
43
+
44
+ def apply!(imap, uid)
45
+ apply_flags(imap, uid)
46
+ apply_copies(imap, uid)
47
+
48
+ # force make the dest message unread if not explicitly marked :Seen
49
+ if !flags.include?(:Seen)
50
+ imap.uid_store(uid, "-FLAGS", [:Seen])
51
+ end
52
+ end
53
+
54
+ protected
55
+
56
+ def apply_flags(imap, uid)
57
+ if !flags.empty?
58
+ imap.uid_store(uid, "+FLAGS", flags)
59
+ end
60
+ end
61
+
62
+ def apply_copies(imap, uid)
63
+ copies.each do |folder|
64
+ begin
65
+ imap.uid_copy(uid, folder)
66
+ rescue Net::IMAP::NoResponseError
67
+ imap.create(folder)
68
+ retry
69
+ end
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def args_collection(args)
76
+ args.
77
+ flatten.
78
+ compact.
79
+ map {|f| f.to_s}.
80
+ reject {|f| f.empty?}.
81
+ uniq
82
+ end
83
+
84
+ end
85
+
86
+ end
@@ -25,7 +25,13 @@ module InboxSync
25
25
  end
26
26
 
27
27
  def meta
28
- @meta ||= @imap.uid_fetch(self.uid, ['RFC822', 'INTERNALDATE']).first
28
+ @meta ||= begin
29
+ fetch_data = @imap.uid_fetch(self.uid, ['RFC822', 'INTERNALDATE'])
30
+ if fetch_data.nil? || fetch_data.empty?
31
+ raise "error fetching data for uid '#{self.uid}'"
32
+ end
33
+ fetch_data.first
34
+ end
29
35
  end
30
36
 
31
37
  def rfc822
@@ -69,7 +75,11 @@ module InboxSync
69
75
  private
70
76
 
71
77
  def time_s(datetime)
72
- datetime.strftime("%a %b %-d %Y, %I:%M %p")
78
+ if datetime && datetime.respond_to?(:strftime)
79
+ datetime.strftime("%a %b %-d %Y, %I:%M %p")
80
+ else
81
+ datetime
82
+ end
73
83
  end
74
84
 
75
85
  def copy_mail_item(item)
@@ -2,6 +2,7 @@ require 'net/imap'
2
2
  require 'net/smtp'
3
3
 
4
4
  require 'inbox-sync/config'
5
+ require 'inbox-sync/filter_actions'
5
6
  require 'inbox-sync/notice/sync_mail_item_error'
6
7
 
7
8
  module InboxSync
@@ -56,9 +57,10 @@ module InboxSync
56
57
  return if runner && runner.shutdown?
57
58
  each_source_mail_item(runner) do |mail_item|
58
59
  begin
60
+ logger.debug "** #{mail_item.inspect}"
59
61
  response = send_to_dest(mail_item)
60
62
  dest_uid = parse_append_response_uid(response)
61
- logger.debug "** dest uid: #{dest_uid.inspect}"
63
+ apply_dest_filters(dest_uid)
62
64
  rescue Exception => err
63
65
  log_error(err)
64
66
  notify(Notice::SyncMailItemError.new(@notify_smtp, @config.notify, {
@@ -106,7 +108,6 @@ module InboxSync
106
108
  logger.info "* the runner has been shutdown - aborting the sync"
107
109
  break
108
110
  end
109
- logger.debug "** #{mail_item.inspect}"
110
111
  yield mail_item
111
112
  end
112
113
  items = nil
@@ -143,6 +144,21 @@ module InboxSync
143
144
  end
144
145
  end
145
146
 
147
+ def apply_dest_filters(dest_uid)
148
+ logger.info "** applying filters for dest uid: #{dest_uid.inspect}"
149
+ using_dest_imap do |imap|
150
+ dest_mail_item = MailItem.new(imap, dest_uid)
151
+ logger.debug "** #{dest_mail_item.inspect}"
152
+
153
+ actions = FilterActions.new(dest_mail_item.message)
154
+ actions.match!(@config.filters)
155
+ logger.debug "** flag as: #{actions.flags.inspect}"
156
+ logger.debug "** copy to: #{actions.copies.inspect}"
157
+
158
+ actions.apply!(imap, dest_uid)
159
+ end
160
+ end
161
+
146
162
  def archive_on_source(mail_item)
147
163
  folder = @config.archive_folder
148
164
  if !folder.nil? && !folder.empty?
@@ -164,10 +180,7 @@ module InboxSync
164
180
  end
165
181
 
166
182
  mark_as_deleted(@source_imap, mail_item.uid)
167
-
168
183
  expunge_imap(@source_imap, @config.source)
169
-
170
- @source_imap.expunge
171
184
  end
172
185
 
173
186
  def using_dest_imap
@@ -237,7 +250,7 @@ module InboxSync
237
250
  # #<struct Net::IMAP::TaggedResponse tag="RUBY0012", name="OK", data=#<struct Net::IMAP::ResponseText code=#<struct Net::IMAP::ResponseCode name="APPENDUID", data="6 9">, text=" (Success)">, raw_data="RUBY0012 OK [APPENDUID 6 9] (Success)\r\n">
238
251
  # (here '9' is the UID)
239
252
  def parse_append_response_uid(response)
240
- response.data.code.data.split(/\s+/).last
253
+ response.data.code.data.split(/\s+/).last.to_i
241
254
  end
242
255
 
243
256
  def mark_as_seen(imap, uid)
@@ -1,3 +1,3 @@
1
1
  module InboxSync
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -0,0 +1,31 @@
1
+ require 'assert'
2
+ require 'inbox-sync/config/filter'
3
+
4
+ module InboxSync
5
+
6
+ class ConfigFilterTests < Assert::Context
7
+ before do
8
+ @filter = InboxSync::Config::Filter.new({
9
+ :subject => 'test',
10
+ :from => /kellyredding.com\Z/
11
+ })
12
+ end
13
+ subject { @filter }
14
+
15
+ should have_reader :conditions, :actions
16
+ should have_instance_method :match?
17
+
18
+ should "convert condition values to regexs if not" do
19
+ assert_kind_of Hash, subject.conditions
20
+ subject.conditions.each do |k,v|
21
+ assert_kind_of Regexp, v
22
+ end
23
+ end
24
+
25
+ should "know if it matches a message" do
26
+ assert subject.match?(test_mail_item.message)
27
+ end
28
+
29
+ end
30
+
31
+ end
data/test/config_test.rb CHANGED
@@ -37,7 +37,14 @@ module InboxSync
37
37
  :default => STDOUT
38
38
  }
39
39
 
40
- should have_instance_method :validate!
40
+ should have_option :filters, {
41
+ :default => [],
42
+ :required => true
43
+ }
44
+
45
+ should have_instance_methods :validate!, :filter
46
+ should have_instance_methods :contains, :like, :includes, :inc
47
+ should have_instance_methods :starts_with, :ends_with, :sw, :ew
41
48
 
42
49
  should "complain if missing :source config" do
43
50
  assert_raises ArgumentError do
@@ -0,0 +1,70 @@
1
+ require 'assert'
2
+ require 'inbox-sync/filter_actions'
3
+
4
+ module InboxSync
5
+
6
+ class FilterActionsTests < Assert::Context
7
+ before do
8
+ @message = test_mail_item.message
9
+ @filter_actions = InboxSync::FilterActions.new(@message)
10
+ end
11
+ subject { @filter_actions }
12
+
13
+ should have_reader :message
14
+ should have_instance_methods :copies, :flags
15
+
16
+ should have_instance_methods :copy_to, :label
17
+ should have_instance_methods :move_to, :archive_to
18
+ should have_instance_methods :flag, :mark_read, :delete
19
+
20
+ should have_instance_methods :match!, :apply!
21
+
22
+ should "have no marks or copies and not be deleted by default" do
23
+ assert_empty subject.copies
24
+ assert_empty subject.flags
25
+ end
26
+
27
+ should "handle copy actions" do
28
+ subject.copy_to 'somewhere'
29
+ subject.label 'something'
30
+
31
+ assert_equal ['somewhere', 'something'], subject.copies
32
+ end
33
+
34
+ should "only show valid, uniq copies when reading" do
35
+ subject.copy_to 'valid'
36
+ subject.copy_to
37
+ subject.copy_to nil
38
+
39
+ assert_equal ['valid'], subject.copies
40
+ end
41
+
42
+ should "handle move actions as a copy and archive" do
43
+ subject.move_to 'somewhere'
44
+
45
+ assert_equal ['somewhere'], subject.copies
46
+ assert_equal [:Deleted], subject.flags
47
+ end
48
+
49
+ should "handle marking as read" do
50
+ subject.mark_read
51
+
52
+ assert_equal [:Seen], subject.flags
53
+ end
54
+
55
+ should "handle delete actions" do
56
+ subject.delete
57
+
58
+ assert_equal [:Deleted], subject.flags
59
+ end
60
+
61
+ should "handle flag actions" do
62
+ subject.flag :A
63
+ subject.flag :B, [:C]
64
+
65
+ assert_equal [:A, :B, :C], subject.flags
66
+ end
67
+
68
+ end
69
+
70
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inbox-sync
3
3
  version: !ruby/object:Gem::Version
4
- hash: 21
4
+ hash: 19
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
- - 2
9
- - 1
10
- version: 0.2.1
8
+ - 3
9
+ - 0
10
+ version: 0.3.0
11
11
  platform: ruby
12
12
  authors:
13
13
  - Kelly Redding
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2012-06-13 00:00:00 Z
18
+ date: 2012-06-15 00:00:00 Z
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency
21
21
  type: :development
@@ -81,8 +81,10 @@ files:
81
81
  - lib/inbox-sync.rb
82
82
  - lib/inbox-sync/config.rb
83
83
  - lib/inbox-sync/config/credentials.rb
84
+ - lib/inbox-sync/config/filter.rb
84
85
  - lib/inbox-sync/config/imap_config.rb
85
86
  - lib/inbox-sync/config/smtp_config.rb
87
+ - lib/inbox-sync/filter_actions.rb
86
88
  - lib/inbox-sync/mail_item.rb
87
89
  - lib/inbox-sync/notice/base.rb
88
90
  - lib/inbox-sync/notice/run_sync_error.rb
@@ -91,7 +93,9 @@ files:
91
93
  - lib/inbox-sync/sync.rb
92
94
  - lib/inbox-sync/version.rb
93
95
  - log/.gitkeep
96
+ - test/config/filter_test.rb
94
97
  - test/config_test.rb
98
+ - test/filter_actions_test.rb
95
99
  - test/helper.rb
96
100
  - test/irb.rb
97
101
  - test/mail_item_test.rb
@@ -132,7 +136,9 @@ signing_key:
132
136
  specification_version: 3
133
137
  summary: Move messages from one inbox to another
134
138
  test_files:
139
+ - test/config/filter_test.rb
135
140
  - test/config_test.rb
141
+ - test/filter_actions_test.rb
136
142
  - test/helper.rb
137
143
  - test/irb.rb
138
144
  - test/mail_item_test.rb