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 +67 -3
- data/lib/inbox-sync/config/filter.rb +41 -0
- data/lib/inbox-sync/config.rb +18 -0
- data/lib/inbox-sync/filter_actions.rb +86 -0
- data/lib/inbox-sync/mail_item.rb +12 -2
- data/lib/inbox-sync/sync.rb +19 -6
- data/lib/inbox-sync/version.rb +1 -1
- data/test/config/filter_test.rb +31 -0
- data/test/config_test.rb +8 -1
- data/test/filter_actions_test.rb +70 -0
- metadata +11 -5
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.
|
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
|
-
|
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
|
-
|
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
|
data/lib/inbox-sync/config.rb
CHANGED
@@ -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
|
data/lib/inbox-sync/mail_item.rb
CHANGED
@@ -25,7 +25,13 @@ module InboxSync
|
|
25
25
|
end
|
26
26
|
|
27
27
|
def meta
|
28
|
-
@meta ||=
|
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
|
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)
|
data/lib/inbox-sync/sync.rb
CHANGED
@@ -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
|
-
|
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)
|
data/lib/inbox-sync/version.rb
CHANGED
@@ -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
|
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:
|
4
|
+
hash: 19
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
|
-
-
|
9
|
-
-
|
10
|
-
version: 0.
|
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-
|
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
|