inbox-sync 0.1.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,123 @@
1
+ require 'inbox-sync/notice/run_sync_error'
2
+
3
+ module InboxSync
4
+
5
+ class Runner
6
+
7
+ attr_reader :syncs, :interval, :logger
8
+
9
+ def initialize(*args)
10
+ opts, syncs = [
11
+ args.last.kind_of?(Hash) ? args.pop : {},
12
+ args.flatten
13
+ ]
14
+
15
+ @syncs = syncs || []
16
+ @interval = opts[:interval].kind_of?(Fixnum) ? opts[:interval] : -1
17
+ @logger = opts[:logger] || Logger.new(STDOUT)
18
+ @shutdown = false
19
+ @running_syncs_thread = nil
20
+
21
+ Signal.trap('SIGINT', lambda{ self.stop })
22
+ Signal.trap('SIGQUIT', lambda{ self.stop })
23
+ end
24
+
25
+ def start
26
+ startup
27
+ loop do
28
+ break if @shutdown
29
+ loop_run
30
+ end
31
+ shutdown
32
+ end
33
+
34
+ def stop
35
+ main_log "Stop signal - waiting for any running syncs to finish."
36
+ @shutdown = true
37
+ end
38
+
39
+ protected
40
+
41
+ def startup
42
+ main_log "Starting up the runner."
43
+ end
44
+
45
+ def shutdown
46
+ main_log "Shutting down the runner"
47
+ end
48
+
49
+ def loop_run
50
+ main_log "Starting syncs in fresh thread."
51
+
52
+ @running_syncs_thread = Thread.new do
53
+ thread_log "starting syncs..."
54
+
55
+ begin
56
+ run_syncs
57
+ rescue Exception => err
58
+ thread_log_error(err, :error)
59
+ end
60
+
61
+ thread_log "...syncs finished"
62
+ end
63
+
64
+ if @interval < 0
65
+ main_log "run-once interval - signaling stop"
66
+ stop
67
+ @interval = 0
68
+ end
69
+
70
+ main_log "Sleeping for #{@interval} seconds."
71
+ sleep(@interval)
72
+ main_log "Woke from sleep - waiting for running syncs thread to join..."
73
+ @running_syncs_thread.join
74
+ main_log "... running sycs thread joined."
75
+ end
76
+
77
+ def run_syncs
78
+ @syncs.each do |sync|
79
+ begin
80
+ sync.setup
81
+ sync.run
82
+ rescue Exception => err
83
+ run_sync_handle_error(sync, err)
84
+ end
85
+
86
+ begin
87
+ sync.teardown
88
+ rescue Exception => err
89
+ run_sync_handle_error(sync, err)
90
+ end
91
+ end
92
+ GC.start
93
+ end
94
+
95
+ def run_sync_handle_error(sync, err)
96
+ thread_log_error(err)
97
+ notice = Notice::RunSyncError.new(sync.notify_smtp, sync.config.notify, {
98
+ :error => err,
99
+ :sync => sync
100
+ })
101
+ sync.notify(notice)
102
+ end
103
+
104
+ def thread_log_error(err, level=:warn)
105
+ thread_log "#{err.message} (#{err.class.name})", level
106
+ err.backtrace.each { |bt| thread_log bt.to_s, level }
107
+ end
108
+
109
+ def main_log(msg, level=:info)
110
+ log "[MAIN]: #{msg}", level
111
+ end
112
+
113
+ def thread_log(msg, level=:info)
114
+ log "[THREAD]: #{msg}", level
115
+ end
116
+
117
+ def log(msg, level)
118
+ @logger.send(level, msg)
119
+ end
120
+
121
+ end
122
+
123
+ end
@@ -0,0 +1,249 @@
1
+ require 'net/imap'
2
+ require 'net/smtp'
3
+
4
+ require 'inbox-sync/config'
5
+ require 'inbox-sync/notice/sync_mail_item_error'
6
+
7
+ module InboxSync
8
+
9
+ class Sync
10
+
11
+ attr_reader :config, :source_imap, :notify_smtp
12
+
13
+ def initialize(configs={})
14
+ @config = InboxSync::Config.new(configs)
15
+ @source_imap = nil
16
+ @notify_smtp = nil
17
+ @logged_in = false
18
+ end
19
+
20
+ def logger
21
+ @config.logger
22
+ end
23
+
24
+ def uid
25
+ "#{@config.source.login.user}:#{@config.source.host}"
26
+ end
27
+
28
+ def name
29
+ "#{@config.source.login.user} (#{@config.source.host})"
30
+ end
31
+
32
+ def logged_in?
33
+ !!@logged_in
34
+ end
35
+
36
+ def configure(&config_block)
37
+ @config.instance_eval(&config_block) if config_block
38
+ self
39
+ end
40
+
41
+ def setup
42
+ logger.info "=== #{config_log_detail(@config.source)} sync started. ==="
43
+
44
+ @notify_smtp ||= setup_smtp(:notify, @config.notify)
45
+ @config.validate!
46
+ login if !logged_in?
47
+ end
48
+
49
+ def teardown
50
+ logout if logged_in?
51
+ @source_imap = @notify_smtp = nil
52
+ logger.info "=== #{config_log_detail(@config.source)} sync finished. ==="
53
+ end
54
+
55
+ def run
56
+ each_source_mail_item do |mail_item|
57
+ begin
58
+ response = send_to_dest(mail_item)
59
+ dest_uid = parse_append_response_uid(response)
60
+ logger.debug "** dest uid: #{dest_uid.inspect}"
61
+ rescue Exception => err
62
+ log_error(err)
63
+ notify(Notice::SyncMailItemError.new(@notify_smtp, @config.notify, {
64
+ :error => err,
65
+ :mail_item => mail_item,
66
+ :sync => self
67
+ }))
68
+ ensure
69
+ archive_on_source(mail_item)
70
+ mail_item = nil
71
+ end
72
+ end
73
+ end
74
+
75
+ def notify(notice)
76
+ logger.info "** sending '#{notice.subject}' to #{notice.to.inspect}"
77
+ begin
78
+ notice.send
79
+ rescue Exception => err
80
+ log_error(err)
81
+ end
82
+ end
83
+
84
+ protected
85
+
86
+ def login
87
+ @source_imap = login_imap(:source, @config.source)
88
+ @logged_in = true
89
+ true
90
+ end
91
+
92
+ def logout
93
+ logout_imap(@source_imap, @config.source)
94
+ @logged_in = false
95
+ true
96
+ end
97
+
98
+ def each_source_mail_item
99
+ items = MailItem.find(@source_imap)
100
+ logger.info "* found #{items.size} mails"
101
+
102
+ items.each do |mail_item|
103
+ logger.debug "** #{mail_item.inspect}"
104
+ yield mail_item
105
+ end
106
+ items = nil
107
+ end
108
+
109
+ # Send a mail item to the destination:
110
+ # The idea here is that destinations may not accept corrupted or invalid
111
+ # mail items. If appending the original mail item in any way fails,
112
+ # create a stripped down version of the mail item and try to append that.
113
+ # If appending the stripped down version still fails, error on up
114
+ # and it will be archived and notified.
115
+
116
+ def send_to_dest(mail_item)
117
+ begin
118
+ append_to_dest(mail_item)
119
+ rescue Exception => err
120
+ log_error(err)
121
+
122
+ logger.debug "** trying to append a the stripped down version"
123
+ append_to_dest(mail_item.stripped, 'stripped down ')
124
+ end
125
+ end
126
+
127
+ def append_to_dest(mail_item, desc='')
128
+ logger.info "** Appending #{desc}#{mail_item.uid} to dest #{@config.dest.inbox}..."
129
+
130
+ inbox = @config.dest.inbox
131
+ mail_s = mail_item.meta['RFC822']
132
+ flags = []
133
+ date = mail_item.meta['INTERNALDATE']
134
+
135
+ using_dest_imap do |imap|
136
+ imap.append(inbox, mail_s, flags, date)
137
+ end
138
+ end
139
+
140
+ def archive_on_source(mail_item)
141
+ folder = @config.archive_folder
142
+ if !folder.nil? && !folder.empty?
143
+ logger.debug "** Archiving #{mail_item.uid.inspect}"
144
+
145
+ begin
146
+ @source_imap.select(folder)
147
+ rescue Net::IMAP::NoResponseError => err
148
+ logger.debug "* Creating #{folder.inspect} archive folder"
149
+ @source_imap.create(folder)
150
+ ensure
151
+ @source_imap.select(@config.source.inbox)
152
+ end
153
+
154
+ mark_as_seen(@source_imap, mail_item.uid)
155
+
156
+ logger.debug "** Copying #{mail_item.uid.inspect} to #{folder.inspect}"
157
+ @source_imap.uid_copy(mail_item.uid, folder)
158
+ end
159
+
160
+ mark_as_deleted(@source_imap, mail_item.uid)
161
+
162
+ expunge_imap(@source_imap, @config.source)
163
+
164
+ @source_imap.expunge
165
+ end
166
+
167
+ def using_dest_imap
168
+ dest_imap = login_imap(:dest, @config.dest)
169
+ result = yield dest_imap
170
+ logout_imap(dest_imap, @config.dest)
171
+ result
172
+ end
173
+
174
+ def login_imap(named, config)
175
+ logger.debug "* LOGIN: #{config_log_detail(config)}"
176
+ begin
177
+ named_imap = Net::IMAP.new(config.host, config.port, config.ssl)
178
+ rescue Errno::ECONNREFUSED => err
179
+ raise Errno::ECONNREFUSED, "#{named} imap {:host => #{config.host}, :port => #{config.port}, :ssl => #{config.ssl}}: #{err.message}"
180
+ end
181
+
182
+ begin
183
+ named_imap.login(config.login.user, config.login.pw)
184
+ rescue Net::IMAP::NoResponseError => err
185
+ raise Net::IMAP::NoResponseError, "#{named} imap #{config.login.to_hash.inspect}: #{err.message}"
186
+ end
187
+
188
+ logger.debug "* SELECT #{config.inbox.inspect}: #{config_log_detail(config)}"
189
+ begin
190
+ named_imap.select(config.inbox)
191
+ rescue Net::IMAP::NoResponseError => err
192
+ raise Net::IMAP::NoResponseError, "#{named} imap: #{err.message}"
193
+ end
194
+
195
+ expunge_imap(named_imap, config)
196
+
197
+ named_imap
198
+ end
199
+
200
+ def expunge_imap(imap, config)
201
+ if config.expunge
202
+ logger.debug "* EXPUNGE #{config.inbox.inspect}: #{config_log_detail(config)}"
203
+ imap.expunge
204
+ end
205
+ end
206
+
207
+ def logout_imap(imap, config)
208
+ logger.debug "* LOGOUT: #{config_log_detail(config)}"
209
+ imap.logout
210
+ end
211
+
212
+ def setup_smtp(named, config)
213
+ logger.debug "* SMTP: #{config_log_detail(config)}"
214
+
215
+ named_smtp = Net::SMTP.new(config.host, config.port)
216
+ named_smtp.enable_starttls if config.tls
217
+
218
+ named_smtp
219
+ end
220
+
221
+ def config_log_detail(config)
222
+ "host=#{config.host.inspect}, user=#{config.login.user.inspect}"
223
+ end
224
+
225
+ def log_error(err)
226
+ logger.warn "#{err.message} (#{err.class.name})"
227
+ err.backtrace.each { |bt| logger.warn bt.to_s }
228
+ end
229
+
230
+ # Given a response like this:
231
+ # #<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">
232
+ # (here '9' is the UID)
233
+ def parse_append_response_uid(response)
234
+ response.data.code.data.split(/\s+/).last
235
+ end
236
+
237
+ def mark_as_seen(imap, uid)
238
+ logger.debug "** Marking #{uid.inspect} as :Seen"
239
+ imap.uid_store(uid, "+FLAGS", [:Seen])
240
+ end
241
+
242
+ def mark_as_deleted(imap, uid)
243
+ logger.debug "** Marking #{uid.inspect} as :Deleted"
244
+ imap.uid_store(uid, "+FLAGS", [:Deleted])
245
+ end
246
+
247
+ end
248
+
249
+ end
@@ -0,0 +1,3 @@
1
+ module InboxSync
2
+ VERSION = "0.1.0"
3
+ end
data/lib/inbox-sync.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'inbox-sync/mail_item'
2
+ require 'inbox-sync/config'
3
+ require 'inbox-sync/sync'
4
+ require 'inbox-sync/runner'
5
+
6
+ module InboxSync
7
+
8
+ def self.new(settings={})
9
+ Sync.new(settings)
10
+ end
11
+
12
+ def self.run(*args)
13
+ Runner.new(*args).start
14
+ end
15
+
16
+ end
data/log/.gitkeep ADDED
File without changes
@@ -0,0 +1,302 @@
1
+ require 'assert'
2
+ require 'ns-options/assert_macros'
3
+
4
+ require 'inbox-sync/config'
5
+
6
+ module InboxSync
7
+
8
+ class ConfigTests < Assert::Context
9
+ include NsOptions::AssertMacros
10
+
11
+ before do
12
+ @config = InboxSync::Config.new
13
+ end
14
+ subject { @config }
15
+
16
+ should have_option :source, InboxSync::Config::IMAPConfig, {
17
+ :default => {},
18
+ :required => true
19
+ }
20
+
21
+ should have_option :dest, InboxSync::Config::IMAPConfig, {
22
+ :default => {},
23
+ :required => true
24
+ }
25
+
26
+ should have_option :notify, InboxSync::Config::SMTPConfig, {
27
+ :default => {},
28
+ :required => true
29
+ }
30
+
31
+ should have_option :archive_folder, {
32
+ :default => "Archived"
33
+ }
34
+
35
+ should have_option :logger, Logger, {
36
+ :required => true,
37
+ :default => STDOUT
38
+ }
39
+
40
+ should have_instance_method :validate!
41
+
42
+ should "complain if missing :source config" do
43
+ assert_raises ArgumentError do
44
+ subject.source = nil
45
+ subject.validate!
46
+ end
47
+ end
48
+
49
+ should "complain if missing :dest config" do
50
+ assert_raises ArgumentError do
51
+ subject.dest = nil
52
+ subject.validate!
53
+ end
54
+ end
55
+
56
+ should "complain if missing :notify config" do
57
+ assert_raises ArgumentError do
58
+ subject.notify = nil
59
+ subject.validate!
60
+ end
61
+ end
62
+
63
+ should "complain if missing :archive_folder config" do
64
+ assert_raises ArgumentError do
65
+ subject.archive_folder = nil
66
+ subject.validate!
67
+ end
68
+ end
69
+
70
+ should "validate its source" do
71
+ assert_raises ArgumentError do
72
+ subject.source.host = nil
73
+ subject.validate!
74
+ end
75
+ end
76
+
77
+ should "validate its dest" do
78
+ assert_raises ArgumentError do
79
+ subject.dest.host = nil
80
+ subject.validate!
81
+ end
82
+ end
83
+
84
+ should "validate its notify" do
85
+ assert_raises ArgumentError do
86
+ subject.notify.host = nil
87
+ subject.validate!
88
+ end
89
+ end
90
+
91
+ end
92
+
93
+ class CredentialsTests < ConfigTests
94
+ subject { @config.source.login }
95
+
96
+ should have_option :user, :required => true
97
+ should have_option :pw, :required => true
98
+
99
+ should have_instance_method :validate!
100
+
101
+ should "complain if missing :user config" do
102
+ assert_raises ArgumentError do
103
+ subject.user = nil
104
+ subject.validate!
105
+ end
106
+ end
107
+
108
+ should "complain if missing :pw config" do
109
+ assert_raises ArgumentError do
110
+ subject.pw = nil
111
+ subject.validate!
112
+ end
113
+ end
114
+
115
+ should "be built from set of args" do
116
+ cred = InboxSync::Config::Credentials.new 'me', 'secret'
117
+
118
+ assert_equal 'me', cred.user
119
+ assert_equal 'secret', cred.pw
120
+ end
121
+
122
+ end
123
+
124
+ class IMAPConfigTests < ConfigTests
125
+ subject { @config.source }
126
+
127
+ should have_option :host, :required => true
128
+
129
+ should have_option :port, {
130
+ :default => 143,
131
+ :required => true
132
+ }
133
+
134
+ should have_option :ssl, NsOptions::Boolean, {
135
+ :default => false,
136
+ :required => true
137
+ }
138
+
139
+ should have_option :login, InboxSync::Config::Credentials, {
140
+ :default => {},
141
+ :required => true
142
+ }
143
+
144
+ should have_option :inbox, {
145
+ :default => "INBOX",
146
+ :required => true
147
+ }
148
+
149
+ should have_option :expunge, NsOptions::Boolean, {
150
+ :default => true,
151
+ :required => true
152
+ }
153
+
154
+ should "complain if missing :host config" do
155
+ assert_raises ArgumentError do
156
+ subject.host = nil
157
+ subject.validate!
158
+ end
159
+ end
160
+
161
+ should "complain if missing :port config" do
162
+ assert_raises ArgumentError do
163
+ subject.port = nil
164
+ subject.validate!
165
+ end
166
+ end
167
+
168
+ should "complain if missing :ssl config" do
169
+ assert_raises ArgumentError do
170
+ subject.ssl = nil
171
+ subject.validate!
172
+ end
173
+ end
174
+
175
+ should "complain if missing :login config" do
176
+ assert_raises ArgumentError do
177
+ subject.login = nil
178
+ subject.validate!
179
+ end
180
+ end
181
+
182
+ should "complain if missing :inbox config" do
183
+ assert_raises ArgumentError do
184
+ subject.inbox = nil
185
+ subject.validate!
186
+ end
187
+ end
188
+
189
+ should "complain if missing :host config" do
190
+ assert_raises ArgumentError do
191
+ subject.expunge = nil
192
+ subject.validate!
193
+ end
194
+ end
195
+
196
+ end
197
+
198
+ class SMTPConfigTests < ConfigTests
199
+ subject { @config.notify }
200
+
201
+ should have_option :host, :required => true
202
+
203
+ should have_option :port, {
204
+ :default => 25,
205
+ :required => true
206
+ }
207
+
208
+ should have_option :tls, NsOptions::Boolean, {
209
+ :default => false,
210
+ :required => true
211
+ }
212
+
213
+ should have_option :helo, :required => true
214
+
215
+ should have_option :login, InboxSync::Config::Credentials, {
216
+ :default => {},
217
+ :required => true
218
+ }
219
+
220
+ should have_option :authtype, {
221
+ :default => :login,
222
+ :required => true
223
+ }
224
+
225
+ should have_option :from_addr, :required => true
226
+
227
+ should have_option :to_addr, :required => true
228
+
229
+ should have_instance_method :validate!
230
+
231
+ should "complain if missing :host config" do
232
+ assert_raises ArgumentError do
233
+ subject.host = nil
234
+ subject.validate!
235
+ end
236
+ end
237
+
238
+ should "complain if missing :port config" do
239
+ assert_raises ArgumentError do
240
+ subject.port = nil
241
+ subject.validate!
242
+ end
243
+ end
244
+
245
+ should "complain if missing :tls config" do
246
+ assert_raises ArgumentError do
247
+ subject.tls = nil
248
+ subject.validate!
249
+ end
250
+ end
251
+
252
+ should "complain if missing :helo config" do
253
+ assert_raises ArgumentError do
254
+ subject.helo = nil
255
+ subject.validate!
256
+ end
257
+ end
258
+
259
+ should "complain if missing :login config" do
260
+ assert_raises ArgumentError do
261
+ subject.login = nil
262
+ subject.validate!
263
+ end
264
+ end
265
+
266
+ should "complain if missing :authtype config" do
267
+ assert_raises ArgumentError do
268
+ subject.authtype = nil
269
+ subject.validate!
270
+ end
271
+ end
272
+
273
+ should "complain if missing :from_addr config" do
274
+ assert_raises ArgumentError do
275
+ subject.from_addr = nil
276
+ subject.validate!
277
+ end
278
+ end
279
+
280
+ should "complain if missing :to_addr config" do
281
+ assert_raises ArgumentError do
282
+ subject.to_addr = nil
283
+ subject.validate!
284
+ end
285
+ end
286
+
287
+ should "complain if empty addrs configs" do
288
+ assert_raises ArgumentError do
289
+ subject.from_addr = ""
290
+ subject.validate!
291
+ end
292
+
293
+ assert_raises ArgumentError do
294
+ subject.to_addr = ""
295
+ subject.validate!
296
+ end
297
+ end
298
+
299
+
300
+ end
301
+
302
+ end