imap-backup 4.0.4 → 4.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/bin/imap-backup +5 -2
  3. data/lib/email/provider/apple_mail.rb +2 -2
  4. data/lib/email/provider/base.rb +8 -0
  5. data/lib/email/provider/fastmail.rb +2 -2
  6. data/lib/email/provider/gmail.rb +2 -2
  7. data/lib/email/provider/{default.rb → unknown.rb} +2 -3
  8. data/lib/email/provider.rb +2 -2
  9. data/lib/imap/backup/account/connection.rb +70 -58
  10. data/lib/imap/backup/account/folder.rb +23 -3
  11. data/lib/imap/backup/account.rb +102 -0
  12. data/lib/imap/backup/cli/accounts.rb +43 -0
  13. data/lib/imap/backup/cli/folders.rb +3 -1
  14. data/lib/imap/backup/cli/helpers.rb +8 -9
  15. data/lib/imap/backup/cli/local.rb +4 -2
  16. data/lib/imap/backup/cli/setup.rb +1 -1
  17. data/lib/imap/backup/cli/utils.rb +3 -2
  18. data/lib/imap/backup/{configuration/store.rb → configuration.rb} +49 -14
  19. data/lib/imap/backup/downloader.rb +26 -12
  20. data/lib/imap/backup/logger.rb +42 -0
  21. data/lib/imap/backup/sanitizer.rb +42 -0
  22. data/lib/imap/backup/serializer/mbox_store.rb +2 -2
  23. data/lib/imap/backup/setup/account.rb +177 -0
  24. data/lib/imap/backup/{configuration → setup}/asker.rb +5 -5
  25. data/lib/imap/backup/setup/connection_tester.rb +26 -0
  26. data/lib/imap/backup/{configuration → setup}/folder_chooser.rb +25 -17
  27. data/lib/imap/backup/setup/helpers.rb +15 -0
  28. data/lib/imap/backup/{configuration/setup.rb → setup.rb} +33 -25
  29. data/lib/imap/backup/uploader.rb +2 -2
  30. data/lib/imap/backup/version.rb +2 -2
  31. data/lib/imap/backup.rb +7 -33
  32. data/lib/retry_on_error.rb +1 -1
  33. data/spec/features/backup_spec.rb +1 -0
  34. data/spec/features/support/email_server.rb +5 -2
  35. data/spec/features/support/shared/connection_context.rb +7 -5
  36. data/spec/support/higline_test_helpers.rb +1 -1
  37. data/spec/support/silence_logging.rb +1 -1
  38. data/spec/unit/email/provider/{default_spec.rb → base_spec.rb} +1 -7
  39. data/spec/unit/email/provider_spec.rb +2 -2
  40. data/spec/unit/imap/backup/account/connection_spec.rb +22 -26
  41. data/spec/unit/imap/backup/cli/accounts_spec.rb +47 -0
  42. data/spec/unit/imap/backup/cli/local_spec.rb +15 -4
  43. data/spec/unit/imap/backup/cli/utils_spec.rb +54 -42
  44. data/spec/unit/imap/backup/{configuration/store_spec.rb → configuration_spec.rb} +23 -24
  45. data/spec/unit/imap/backup/downloader_spec.rb +1 -1
  46. data/spec/unit/imap/backup/logger_spec.rb +48 -0
  47. data/spec/unit/imap/backup/{configuration → setup}/account_spec.rb +78 -70
  48. data/spec/unit/imap/backup/{configuration → setup}/asker_spec.rb +2 -2
  49. data/spec/unit/imap/backup/{configuration → setup}/connection_tester_spec.rb +10 -10
  50. data/spec/unit/imap/backup/{configuration → setup}/folder_chooser_spec.rb +25 -26
  51. data/spec/unit/imap/backup/{configuration/setup_spec.rb → setup_spec.rb} +81 -52
  52. metadata +54 -51
  53. data/lib/imap/backup/configuration/account.rb +0 -159
  54. data/lib/imap/backup/configuration/connection_tester.rb +0 -14
  55. data/lib/imap/backup/configuration/list.rb +0 -53
  56. data/spec/support/shared_examples/account_flagging.rb +0 -23
  57. data/spec/unit/imap/backup/configuration/list_spec.rb +0 -89
  58. data/spec/unit/imap/backup_spec.rb +0 -28
@@ -1,11 +1,13 @@
1
1
  require "json"
2
2
  require "os"
3
3
 
4
- module Imap::Backup
5
- module Configuration; end
4
+ require "imap/backup/account"
6
5
 
7
- class Configuration::Store
6
+ module Imap::Backup
7
+ class Configuration
8
8
  CONFIGURATION_DIRECTORY = File.expand_path("~/.imap-backup")
9
+ DEFAULT_DOWNLOAD_BLOCK_SIZE = 1
10
+ VERSION = "2.0"
9
11
 
10
12
  attr_reader :pathname
11
13
 
@@ -19,6 +21,8 @@ module Imap::Backup
19
21
 
20
22
  def initialize(pathname = self.class.default_pathname)
21
23
  @pathname = pathname
24
+ @saved_debug = nil
25
+ @debug = nil
22
26
  end
23
27
 
24
28
  def path
@@ -26,53 +30,84 @@ module Imap::Backup
26
30
  end
27
31
 
28
32
  def save
33
+ ensure_loaded!
29
34
  FileUtils.mkdir(path) if !File.directory?(path)
30
35
  make_private(path) if !windows?
31
36
  remove_modified_flags
32
37
  remove_deleted_accounts
33
- File.open(pathname, "w") { |f| f.write(JSON.pretty_generate(data)) }
38
+ save_data = {
39
+ version: VERSION,
40
+ accounts: accounts.map(&:to_h),
41
+ debug: debug?
42
+ }
43
+ File.open(pathname, "w") { |f| f.write(JSON.pretty_generate(save_data)) }
34
44
  FileUtils.chmod(0o600, pathname) if !windows?
45
+ @data = nil
35
46
  end
36
47
 
37
48
  def accounts
38
- data[:accounts]
49
+ @accounts ||= begin
50
+ ensure_loaded!
51
+ data[:accounts].map { |data| Account.new(data) }
52
+ end
53
+ end
54
+
55
+ def download_block_size
56
+ size = ENV["DOWNLOAD_BLOCK_SIZE"].to_i
57
+ if size > 0
58
+ size
59
+ else
60
+ DEFAULT_DOWNLOAD_BLOCK_SIZE
61
+ end
39
62
  end
40
63
 
41
64
  def modified?
42
- accounts.any? { |a| a[:modified] || a[:delete] }
65
+ ensure_loaded!
66
+ return true if @saved_debug != @debug
67
+
68
+ accounts.any? { |a| a.modified? || a.marked_for_deletion? }
43
69
  end
44
70
 
45
71
  def debug?
46
- data[:debug]
72
+ ensure_loaded!
73
+ @debug
47
74
  end
48
75
 
49
76
  def debug=(value)
50
- data[:debug] = [true, false].include?(value) ? value : false
77
+ ensure_loaded!
78
+ @debug = [true, false].include?(value) ? value : false
51
79
  end
52
80
 
53
81
  private
54
82
 
83
+ def ensure_loaded!
84
+ return true if @data
85
+
86
+ data
87
+ @debug = data.key?(:debug) ? data[:debug] == true : false
88
+ @saved_debug = @debug
89
+ true
90
+ end
91
+
55
92
  def data
56
93
  @data ||=
57
94
  begin
58
95
  if File.exist?(pathname)
59
96
  Utils.check_permissions(pathname, 0o600) if !windows?
60
97
  contents = File.read(pathname)
61
- data = JSON.parse(contents, symbolize_names: true)
98
+ JSON.parse(contents, symbolize_names: true)
62
99
  else
63
- data = {accounts: []}
100
+ {accounts: []}
64
101
  end
65
- data[:debug] = data.key?(:debug) ? data[:debug] == true : false
66
- data
67
102
  end
68
103
  end
69
104
 
70
105
  def remove_modified_flags
71
- accounts.each { |a| a.delete(:modified) }
106
+ accounts.each { |a| a.clear_changes! }
72
107
  end
73
108
 
74
109
  def remove_deleted_accounts
75
- accounts.reject! { |a| a[:delete] }
110
+ accounts.reject! { |a| a.marked_for_deletion? }
76
111
  end
77
112
 
78
113
  def make_private(path)
@@ -2,27 +2,41 @@ module Imap::Backup
2
2
  class Downloader
3
3
  attr_reader :folder
4
4
  attr_reader :serializer
5
+ attr_reader :block_size
5
6
 
6
- def initialize(folder, serializer)
7
+ def initialize(folder, serializer, block_size: 1)
7
8
  @folder = folder
8
9
  @serializer = serializer
10
+ @block_size = block_size
9
11
  end
10
12
 
11
13
  def run
12
14
  uids = folder.uids - serializer.uids
13
15
  count = uids.count
14
- Imap::Backup.logger.debug "[#{folder.name}] #{count} new messages"
15
- uids.each.with_index do |uid, i|
16
- body = folder.fetch(uid)
17
- log_prefix = "[#{folder.name}] uid: #{uid} (#{i + 1}/#{count}) -"
18
- if body.nil?
19
- Imap::Backup.logger.debug("#{log_prefix} not available - skipped")
20
- next
16
+ Imap::Backup::Logger.logger.debug "[#{folder.name}] #{count} new messages"
17
+ uids.each_slice(block_size).with_index do |block, i|
18
+ offset = i * block_size + 1
19
+ uids_and_bodies = folder.fetch_multi(block)
20
+ if uids_and_bodies.nil?
21
+ if block_size > 1
22
+ Imap::Backup::Logger.logger.debug("[#{folder.name}] Multi fetch failed for UIDs #{block.join(", ")}, switching to single fetches")
23
+ @block_size = 1
24
+ redo
25
+ else
26
+ Imap::Backup::Logger.logger.debug("[#{folder.name}] Fetch failed for UID #{block[0]} - skipping")
27
+ next
28
+ end
29
+ end
30
+
31
+ uids_and_bodies.each.with_index do |uid_and_body, j|
32
+ uid = uid_and_body[:uid]
33
+ body = uid_and_body[:body]
34
+ Imap::Backup::Logger.logger.debug(
35
+ "[#{folder.name}] uid: #{uid} (#{offset +j}/#{count}) - " \
36
+ "#{body.size} bytes"
37
+ )
38
+ serializer.save(uid, body)
21
39
  end
22
- Imap::Backup.logger.debug(
23
- "#{log_prefix} #{body.size} bytes"
24
- )
25
- serializer.save(uid, body)
26
40
  end
27
41
  end
28
42
  end
@@ -0,0 +1,42 @@
1
+ require "logger"
2
+ require "singleton"
3
+
4
+ require "imap/backup/configuration"
5
+ require "imap/backup/sanitizer"
6
+
7
+ module Imap::Backup
8
+ class Logger
9
+ include Singleton
10
+
11
+ def self.logger
12
+ Logger.instance.logger
13
+ end
14
+
15
+ def self.setup_logging(config = Configuration.new)
16
+ logger.level =
17
+ if config.debug?
18
+ ::Logger::Severity::DEBUG
19
+ else
20
+ ::Logger::Severity::ERROR
21
+ end
22
+ Net::IMAP.debug = config.debug?
23
+ end
24
+
25
+ def self.sanitize_stderr
26
+ sanitizer = Sanitizer.new($stdout)
27
+ previous_stderr = $stderr
28
+ $stderr = sanitizer
29
+ yield
30
+ ensure
31
+ sanitizer.flush
32
+ $stderr = previous_stderr
33
+ end
34
+
35
+ attr_reader :logger
36
+
37
+ def initialize
38
+ @logger = ::Logger.new($stdout)
39
+ $stdout.sync = true
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ module Imap::Backup
2
+ class Sanitizer
3
+ attr_reader :output
4
+
5
+ def initialize(output)
6
+ @output = output
7
+ @current = ""
8
+ end
9
+
10
+ def write(*args)
11
+ output.write(*args)
12
+ end
13
+
14
+ def print(*args)
15
+ @current << args.join
16
+ loop do
17
+ line, newline, rest = @current.partition("\n")
18
+ break if newline != "\n"
19
+ clean = sanitize(line)
20
+ output.puts clean
21
+ @current = rest
22
+ end
23
+ end
24
+
25
+ def flush
26
+ return if @current == ""
27
+
28
+ clean = sanitize(@current)
29
+ output.puts clean
30
+ end
31
+
32
+ private
33
+
34
+ def sanitize(t)
35
+ # Hide password in Net::IMAP debug output
36
+ t.gsub(
37
+ /\A(C: RUBY\d+ LOGIN \S+) \S+/,
38
+ "\\1 [PASSWORD REDACTED]"
39
+ )
40
+ end
41
+ end
42
+ end
@@ -46,7 +46,7 @@ module Imap::Backup
46
46
 
47
47
  uid = uid.to_i
48
48
  if uids.include?(uid)
49
- Imap::Backup.logger.debug(
49
+ Imap::Backup::Logger.logger.debug(
50
50
  "[#{folder}] message #{uid} already downloaded - skipping"
51
51
  )
52
52
  return
@@ -65,7 +65,7 @@ module Imap::Backup
65
65
  #{body}. #{e}:
66
66
  #{e.backtrace.join("\n")}"
67
67
  ERROR
68
- Imap::Backup.logger.warn message
68
+ Imap::Backup::Logger.logger.warn message
69
69
  ensure
70
70
  mbox&.close
71
71
  end
@@ -0,0 +1,177 @@
1
+ require "imap/backup/setup/helpers"
2
+
3
+ module Imap::Backup
4
+ class Setup; end
5
+
6
+ Setup::Account = Struct.new(:config, :account, :highline) do
7
+ def initialize(config, account, highline)
8
+ super
9
+ end
10
+
11
+ def run
12
+ catch :done do
13
+ loop do
14
+ Kernel.system("clear")
15
+ create_menu
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def create_menu
23
+ highline.choose do |menu|
24
+ header menu
25
+ modify_email menu
26
+ modify_password menu
27
+ modify_backup_path menu
28
+ choose_folders menu
29
+ modify_server menu
30
+ modify_connection_options menu
31
+ test_connection menu
32
+ delete_account menu
33
+ menu.choice("(q) return to main menu") { throw :done }
34
+ menu.hidden("quit") { throw :done }
35
+ end
36
+ end
37
+
38
+ def header(menu)
39
+ modified = account.modified? ? "*" : ""
40
+ connection_options =
41
+ if account.connection_options
42
+ escaped =
43
+ JSON.generate(account.connection_options).
44
+ gsub('"', '\"')
45
+ "\n connection options #{escaped}"
46
+ end
47
+ menu.header = <<~HEADER.chomp
48
+ #{helpers.title_prefix} Account#{modified}
49
+
50
+ email #{account.username}
51
+ password #{masked_password}
52
+ path #{account.local_path}
53
+ folders #{folders.map { |f| f[:name] }.join(', ')}
54
+ server #{account.server}#{connection_options}
55
+
56
+ Choose an action
57
+ HEADER
58
+ end
59
+
60
+ def modify_email(menu)
61
+ menu.choice("modify email") do
62
+ username = Setup::Asker.email(username)
63
+ Kernel.puts "username: #{username}"
64
+ other_accounts = config.accounts.reject { |a| a == account }
65
+ others = other_accounts.map { |a| a.username }
66
+ Kernel.puts "others: #{others.inspect}"
67
+ if others.include?(username)
68
+ Kernel.puts(
69
+ "There is already an account set up with that email address"
70
+ )
71
+ else
72
+ account.username = username
73
+ # rubocop:disable Style/IfUnlessModifier
74
+ default = default_server(username)
75
+ if default && (account.server.nil? || (account.server == ""))
76
+ account.server = default
77
+ end
78
+ # rubocop:enable Style/IfUnlessModifier
79
+ end
80
+ end
81
+ end
82
+
83
+ def modify_password(menu)
84
+ menu.choice("modify password") do
85
+ password = Setup::Asker.password
86
+
87
+ account.password = password if !password.nil?
88
+ end
89
+ end
90
+
91
+ def modify_server(menu)
92
+ menu.choice("modify server") do
93
+ server = highline.ask("server: ")
94
+ account.server = server if !server.nil?
95
+ end
96
+ end
97
+
98
+ def modify_connection_options(menu)
99
+ menu.choice("modify connection options") do
100
+ connection_options = highline.ask("connections options (as JSON): ")
101
+ account.connection_options = connection_options if !connection_options.nil?
102
+ end
103
+ end
104
+
105
+ def path_modification_validator(path)
106
+ same = config.accounts.find do |a|
107
+ a.username != account.username && a.local_path == path
108
+ end
109
+ if same
110
+ Kernel.puts "The path '#{path}' is used to backup " \
111
+ "the account '#{same.username}'"
112
+ false
113
+ else
114
+ true
115
+ end
116
+ end
117
+
118
+ def modify_backup_path(menu)
119
+ menu.choice("modify backup path") do
120
+ existing = account.local_path.clone
121
+ account.local_path = Setup::Asker.backup_path(
122
+ account.local_path, ->(path) { path_modification_validator(path) }
123
+ )
124
+ end
125
+ end
126
+
127
+ def choose_folders(menu)
128
+ menu.choice("choose backup folders") do
129
+ Setup::FolderChooser.new(account).run
130
+ end
131
+ end
132
+
133
+ def test_connection(menu)
134
+ menu.choice("test connection") do
135
+ result = Setup::ConnectionTester.new(account).test
136
+ Kernel.puts result
137
+ highline.ask "Press a key "
138
+ end
139
+ end
140
+
141
+ def delete_account(menu)
142
+ menu.choice("delete") do
143
+ if highline.agree("Are you sure? (y/n) ")
144
+ account.mark_for_deletion!
145
+ throw :done
146
+ end
147
+ end
148
+ end
149
+
150
+ def folders
151
+ account.folders || []
152
+ end
153
+
154
+ def masked_password
155
+ if (account.password == "") || account.password.nil?
156
+ "(unset)"
157
+ else
158
+ account.password.gsub(/./, "x")
159
+ end
160
+ end
161
+
162
+ def default_server(username)
163
+ provider = Email::Provider.for_address(username)
164
+
165
+ if provider.is_a?(Email::Provider::Unknown)
166
+ Kernel.puts "Can't decide provider for email address '#{username}'"
167
+ return nil
168
+ end
169
+
170
+ provider.host
171
+ end
172
+
173
+ def helpers
174
+ Setup::Helpers.new
175
+ end
176
+ end
177
+ end
@@ -1,7 +1,7 @@
1
1
  module Imap::Backup
2
- module Configuration; end
2
+ class Setup; end
3
3
 
4
- Configuration::Asker = Struct.new(:highline) do
4
+ Setup::Asker = Struct.new(:highline) do
5
5
  EMAIL_MATCHER = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]+$/i.freeze
6
6
 
7
7
  def initialize(highline)
@@ -40,15 +40,15 @@ module Imap::Backup
40
40
  end
41
41
 
42
42
  def self.email(default = "")
43
- new(Configuration::Setup.highline).email(default)
43
+ new(Setup.highline).email(default)
44
44
  end
45
45
 
46
46
  def self.password
47
- new(Configuration::Setup.highline).password
47
+ new(Setup.highline).password
48
48
  end
49
49
 
50
50
  def self.backup_path(default, validator)
51
- new(Configuration::Setup.highline).backup_path(default, validator)
51
+ new(Setup.highline).backup_path(default, validator)
52
52
  end
53
53
  end
54
54
  end
@@ -0,0 +1,26 @@
1
+ module Imap::Backup
2
+ class Setup; end
3
+
4
+ class Setup::ConnectionTester
5
+ attr_reader :account
6
+
7
+ def initialize(account)
8
+ @account = account
9
+ end
10
+
11
+ def test
12
+ connection.client
13
+ "Connection successful"
14
+ rescue Net::IMAP::NoResponseError
15
+ "No response"
16
+ rescue StandardError => e
17
+ "Unexpected error: #{e}"
18
+ end
19
+
20
+ private
21
+
22
+ def connection
23
+ Account::Connection.new(account)
24
+ end
25
+ end
26
+ end
@@ -1,7 +1,9 @@
1
+ require "imap/backup/setup/helpers"
2
+
1
3
  module Imap::Backup
2
- module Configuration; end
4
+ class Setup; end
3
5
 
4
- class Configuration::FolderChooser
6
+ class Setup::FolderChooser
5
7
  attr_reader :account
6
8
 
7
9
  def initialize(account)
@@ -10,13 +12,13 @@ module Imap::Backup
10
12
 
11
13
  def run
12
14
  if connection.nil?
13
- Imap::Backup.logger.warn "Connection failed"
15
+ Imap::Backup::Logger.logger.warn "Connection failed"
14
16
  highline.ask "Press a key "
15
17
  return
16
18
  end
17
19
 
18
20
  if imap_folders.nil?
19
- Imap::Backup.logger.warn "Unable to get folder list"
21
+ Imap::Backup::Logger.logger.warn "Unable to get folder list"
20
22
  highline.ask "Press a key "
21
23
  return
22
24
  end
@@ -35,10 +37,14 @@ module Imap::Backup
35
37
 
36
38
  def show_menu
37
39
  highline.choose do |menu|
38
- menu.header = "Add/remove folders"
40
+ menu.header = <<~MENU.chomp
41
+ #{helpers.title_prefix} Add/remove folders
42
+
43
+ Select a folder (toggles)
44
+ MENU
39
45
  menu.index = :number
40
46
  add_folders menu
41
- menu.choice("return to the account menu") { throw :done }
47
+ menu.choice("(q) return to the account menu") { throw :done }
42
48
  menu.hidden("quit") { throw :done }
43
49
  end
44
50
  end
@@ -53,7 +59,7 @@ module Imap::Backup
53
59
  end
54
60
 
55
61
  def selected?(folder_name)
56
- config_folders = account[:folders]
62
+ config_folders = account.folders
57
63
  return false if config_folders.nil?
58
64
 
59
65
  config_folders.find { |f| f[:name] == folder_name }
@@ -62,7 +68,7 @@ module Imap::Backup
62
68
  def remove_missing
63
69
  removed = []
64
70
  config_folders = []
65
- account[:folders].each do |f|
71
+ account.folders.each do |f|
66
72
  found = imap_folders.find { |folder| folder == f[:name] }
67
73
  if found
68
74
  config_folders << f
@@ -73,8 +79,7 @@ module Imap::Backup
73
79
 
74
80
  return if removed.empty?
75
81
 
76
- account[:folders] = config_folders
77
- account[:modified] = true
82
+ account.folders = config_folders
78
83
 
79
84
  Kernel.puts <<~MESSAGE
80
85
  The following folders have been removed: #{removed.join(', ')}
@@ -85,12 +90,11 @@ module Imap::Backup
85
90
 
86
91
  def toggle_selection(folder_name)
87
92
  if selected?(folder_name)
88
- changed = account[:folders].reject! { |f| f[:name] == folder_name }
89
- account[:modified] = true if changed
93
+ new_list = account.folders.select { |f| f[:name] != folder_name }
94
+ account.folders = new_list
90
95
  else
91
- account[:folders] ||= []
92
- account[:folders] << {name: folder_name}
93
- account[:modified] = true
96
+ existing = account.folders || []
97
+ account.folders = existing + [{name: folder_name}]
94
98
  end
95
99
  end
96
100
 
@@ -101,11 +105,15 @@ module Imap::Backup
101
105
  end
102
106
 
103
107
  def imap_folders
104
- @imap_folders ||= connection.folders
108
+ @imap_folders ||= connection.folder_names
105
109
  end
106
110
 
107
111
  def highline
108
- Configuration::Setup.highline
112
+ Setup.highline
113
+ end
114
+
115
+ def helpers
116
+ Setup::Helpers.new
109
117
  end
110
118
  end
111
119
  end
@@ -0,0 +1,15 @@
1
+ require "imap/backup/version"
2
+
3
+ module Imap::Backup
4
+ class Setup; end
5
+
6
+ class Setup::Helpers
7
+ def title_prefix
8
+ "imap-backup –"
9
+ end
10
+
11
+ def version
12
+ Version::VERSION
13
+ end
14
+ end
15
+ end