imap-backup 5.2.0 → 6.0.1

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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -2
  3. data/docs/development.md +10 -4
  4. data/imap-backup.gemspec +5 -1
  5. data/lib/cli_coverage.rb +11 -11
  6. data/lib/email/provider/base.rb +2 -0
  7. data/lib/email/provider/unknown.rb +2 -0
  8. data/lib/email/provider.rb +2 -0
  9. data/lib/imap/backup/account/connection/backup_folders.rb +27 -0
  10. data/lib/imap/backup/account/connection/client_factory.rb +54 -0
  11. data/lib/imap/backup/account/connection/folder_names.rb +26 -0
  12. data/lib/imap/backup/account/connection.rb +17 -105
  13. data/lib/imap/backup/account/folder.rb +9 -6
  14. data/lib/imap/backup/account.rb +36 -16
  15. data/lib/imap/backup/cli/backup.rb +1 -3
  16. data/lib/imap/backup/cli/folders.rb +3 -3
  17. data/lib/imap/backup/cli/helpers.rb +24 -22
  18. data/lib/imap/backup/cli/local.rb +20 -13
  19. data/lib/imap/backup/cli/migrate.rb +5 -11
  20. data/lib/imap/backup/cli/restore.rb +8 -7
  21. data/lib/imap/backup/cli/setup.rb +10 -8
  22. data/lib/imap/backup/cli/stats.rb +78 -0
  23. data/lib/imap/backup/cli/status.rb +2 -2
  24. data/lib/imap/backup/cli/utils.rb +6 -8
  25. data/lib/imap/backup/cli.rb +24 -3
  26. data/lib/imap/backup/configuration.rb +9 -21
  27. data/lib/imap/backup/downloader.rb +56 -34
  28. data/lib/imap/backup/migrator.rb +5 -5
  29. data/lib/imap/backup/sanitizer.rb +3 -2
  30. data/lib/imap/backup/serializer/appender.rb +49 -0
  31. data/lib/imap/backup/serializer/directory.rb +37 -0
  32. data/lib/imap/backup/serializer/imap.rb +144 -0
  33. data/lib/imap/backup/serializer/mbox.rb +33 -88
  34. data/lib/imap/backup/serializer/mbox_enumerator.rb +2 -0
  35. data/lib/imap/backup/serializer/message_enumerator.rb +29 -0
  36. data/lib/imap/backup/serializer/unused_name_finder.rb +25 -0
  37. data/lib/imap/backup/serializer.rb +160 -3
  38. data/lib/imap/backup/setup/account/header.rb +75 -0
  39. data/lib/imap/backup/setup/account.rb +41 -95
  40. data/lib/imap/backup/setup/asker.rb +4 -15
  41. data/lib/imap/backup/setup/backup_path.rb +41 -0
  42. data/lib/imap/backup/setup/email.rb +45 -0
  43. data/lib/imap/backup/setup/folder_chooser.rb +3 -3
  44. data/lib/imap/backup/setup/helpers.rb +2 -2
  45. data/lib/imap/backup/setup.rb +5 -4
  46. data/lib/imap/backup/thunderbird/mailbox_exporter.rb +41 -22
  47. data/lib/imap/backup/uploader.rb +46 -8
  48. data/lib/imap/backup/utils.rb +1 -1
  49. data/lib/imap/backup/version.rb +3 -3
  50. data/lib/imap/backup.rb +0 -2
  51. metadata +31 -105
  52. data/lib/imap/backup/serializer/mbox_store.rb +0 -217
  53. data/spec/features/backup_spec.rb +0 -108
  54. data/spec/features/configuration/minimal_configuration.rb +0 -15
  55. data/spec/features/configuration/missing_configuration.rb +0 -14
  56. data/spec/features/folders_spec.rb +0 -36
  57. data/spec/features/helper.rb +0 -2
  58. data/spec/features/local/list_accounts_spec.rb +0 -12
  59. data/spec/features/local/list_emails_spec.rb +0 -21
  60. data/spec/features/local/list_folders_spec.rb +0 -21
  61. data/spec/features/local/show_an_email_spec.rb +0 -34
  62. data/spec/features/migrate_spec.rb +0 -35
  63. data/spec/features/remote/list_account_folders_spec.rb +0 -16
  64. data/spec/features/restore_spec.rb +0 -162
  65. data/spec/features/status_spec.rb +0 -43
  66. data/spec/features/support/aruba.rb +0 -77
  67. data/spec/features/support/backup_directory.rb +0 -43
  68. data/spec/features/support/email_server.rb +0 -110
  69. data/spec/features/support/shared/connection_context.rb +0 -14
  70. data/spec/features/support/shared/message_fixtures.rb +0 -16
  71. data/spec/fixtures/connection.yml +0 -7
  72. data/spec/spec_helper.rb +0 -15
  73. data/spec/support/fixtures.rb +0 -11
  74. data/spec/support/higline_test_helpers.rb +0 -8
  75. data/spec/support/silence_logging.rb +0 -7
  76. data/spec/unit/email/mboxrd/message_spec.rb +0 -177
  77. data/spec/unit/email/provider/apple_mail_spec.rb +0 -7
  78. data/spec/unit/email/provider/base_spec.rb +0 -11
  79. data/spec/unit/email/provider/fastmail_spec.rb +0 -7
  80. data/spec/unit/email/provider/gmail_spec.rb +0 -7
  81. data/spec/unit/email/provider_spec.rb +0 -27
  82. data/spec/unit/imap/backup/account/connection_spec.rb +0 -405
  83. data/spec/unit/imap/backup/account/folder_spec.rb +0 -251
  84. data/spec/unit/imap/backup/cli/accounts_spec.rb +0 -47
  85. data/spec/unit/imap/backup/cli/helpers_spec.rb +0 -87
  86. data/spec/unit/imap/backup/cli/local_spec.rb +0 -81
  87. data/spec/unit/imap/backup/cli/utils_spec.rb +0 -62
  88. data/spec/unit/imap/backup/client/default_spec.rb +0 -22
  89. data/spec/unit/imap/backup/configuration_spec.rb +0 -238
  90. data/spec/unit/imap/backup/downloader_spec.rb +0 -44
  91. data/spec/unit/imap/backup/logger_spec.rb +0 -48
  92. data/spec/unit/imap/backup/migrator_spec.rb +0 -58
  93. data/spec/unit/imap/backup/serializer/mbox_enumerator_spec.rb +0 -45
  94. data/spec/unit/imap/backup/serializer/mbox_spec.rb +0 -222
  95. data/spec/unit/imap/backup/serializer/mbox_store_spec.rb +0 -329
  96. data/spec/unit/imap/backup/setup/account_spec.rb +0 -366
  97. data/spec/unit/imap/backup/setup/asker_spec.rb +0 -137
  98. data/spec/unit/imap/backup/setup/connection_tester_spec.rb +0 -51
  99. data/spec/unit/imap/backup/setup/folder_chooser_spec.rb +0 -146
  100. data/spec/unit/imap/backup/setup_spec.rb +0 -301
  101. data/spec/unit/imap/backup/uploader_spec.rb +0 -54
  102. data/spec/unit/imap/backup/utils_spec.rb +0 -92
@@ -0,0 +1,37 @@
1
+ require "os"
2
+
3
+ module Imap::Backup
4
+ class Serializer; end
5
+
6
+ class Serializer::Directory
7
+ DIRECTORY_PERMISSIONS = 0o700
8
+
9
+ attr_reader :relative
10
+ attr_reader :path
11
+
12
+ def initialize(path, relative)
13
+ @path = path
14
+ @relative = relative
15
+ end
16
+
17
+ def ensure_exists
18
+ if !File.directory?(full_path)
19
+ Utils.make_folder(
20
+ path, relative, DIRECTORY_PERMISSIONS
21
+ )
22
+ end
23
+
24
+ return if OS.windows?
25
+ return if Utils.mode(full_path) == DIRECTORY_PERMISSIONS
26
+
27
+ FileUtils.chmod DIRECTORY_PERMISSIONS, full_path
28
+ end
29
+
30
+ private
31
+
32
+ def full_path
33
+ containing_directory = File.join(path, relative)
34
+ File.expand_path(containing_directory)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,144 @@
1
+ require "json"
2
+
3
+ module Imap::Backup
4
+ class Serializer::Imap
5
+ CURRENT_VERSION = 2
6
+
7
+ attr_reader :folder_path
8
+ attr_reader :loaded
9
+
10
+ def initialize(folder_path)
11
+ @folder_path = folder_path
12
+ @loaded = false
13
+ @uid_validity = nil
14
+ @uids = nil
15
+ @version = nil
16
+ end
17
+
18
+ def valid?
19
+ return false if !exist?
20
+ return false if version != CURRENT_VERSION
21
+ return false if !uid_validity
22
+
23
+ true
24
+ end
25
+
26
+ def append(uid)
27
+ uids << uid
28
+ save
29
+ end
30
+
31
+ def delete
32
+ return if !exist?
33
+
34
+ File.unlink(pathname)
35
+ end
36
+
37
+ def include?(uid)
38
+ uids.include?(uid)
39
+ end
40
+
41
+ def index(uid)
42
+ uids.find_index(uid)
43
+ end
44
+
45
+ def rename(new_path)
46
+ if exist?
47
+ old_pathname = pathname
48
+ @folder_path = new_path
49
+ File.rename(old_pathname, pathname)
50
+ else
51
+ @folder_path = new_path
52
+ end
53
+ end
54
+
55
+ def uid_validity
56
+ ensure_loaded
57
+ @uid_validity
58
+ end
59
+
60
+ def uid_validity=(value)
61
+ ensure_loaded
62
+ @uid_validity = value
63
+ @uids ||= []
64
+ save
65
+ end
66
+
67
+ def uids
68
+ ensure_loaded
69
+ @uids || []
70
+ end
71
+
72
+ def update_uid(old, new)
73
+ index = uids.find_index(old.to_i)
74
+ return if index.nil?
75
+
76
+ uids[index] = new.to_i
77
+ save
78
+ end
79
+
80
+ def version
81
+ ensure_loaded
82
+ @version
83
+ end
84
+
85
+ private
86
+
87
+ def pathname
88
+ "#{folder_path}.imap"
89
+ end
90
+
91
+ def exist?
92
+ File.exist?(pathname)
93
+ end
94
+
95
+ def ensure_loaded
96
+ return if loaded
97
+
98
+ data = load
99
+ if data
100
+ @uids = data[:uids].map(&:to_i)
101
+ @uid_validity = data[:uid_validity]
102
+ @version = data[:version]
103
+ else
104
+ @uids = []
105
+ @uid_validity = nil
106
+ @version = CURRENT_VERSION
107
+ end
108
+ @loaded = true
109
+ end
110
+
111
+ def load
112
+ return nil if !exist?
113
+
114
+ data = nil
115
+ begin
116
+ content = File.read(pathname)
117
+ data = JSON.parse(content, symbolize_names: true)
118
+ rescue JSON::ParserError
119
+ return nil
120
+ end
121
+
122
+ return nil if !data.key?(:version)
123
+ return nil if !data.key?(:uid_validity)
124
+ return nil if !data.key?(:uids)
125
+ return nil if !data[:uids].is_a?(Array)
126
+
127
+ data
128
+ end
129
+
130
+ def save
131
+ ensure_loaded
132
+
133
+ raise "Cannot save metadata without a uid_validity" if !uid_validity
134
+
135
+ data = {
136
+ version: @version,
137
+ uid_validity: @uid_validity,
138
+ uids: @uids
139
+ }
140
+ content = data.to_json
141
+ File.open(pathname, "w") { |f| f.write content }
142
+ end
143
+ end
144
+ end
@@ -1,116 +1,61 @@
1
- require "forwardable"
2
-
3
- require "imap/backup/serializer/mbox_store"
4
-
5
1
  module Imap::Backup
6
2
  class Serializer::Mbox
7
- extend Forwardable
8
- def_delegators :store, :mbox_pathname
9
-
10
- attr_reader :path
11
- attr_reader :folder
12
-
13
- def initialize(path, folder)
14
- @path = path
15
- @folder = folder
16
- end
17
-
18
- def apply_uid_validity(value)
19
- case
20
- when store.uid_validity.nil?
21
- store.uid_validity = value
22
- nil
23
- when store.uid_validity == value
24
- # NOOP
25
- nil
26
- else
27
- apply_new_uid_validity value
28
- end
29
- end
3
+ attr_reader :folder_path
30
4
 
31
- def force_uid_validity(value)
32
- store.uid_validity = value
5
+ def initialize(folder_path)
6
+ @folder_path = folder_path
33
7
  end
34
8
 
35
- def uids
36
- store.uids
9
+ def valid?
10
+ exist?
37
11
  end
38
12
 
39
- def load(uid)
40
- store.load(uid)
13
+ def append(message)
14
+ File.open(pathname, "ab") do |file|
15
+ file.write message
16
+ end
41
17
  end
42
18
 
43
- def each_message(uids)
44
- store.each_message(uids)
45
- end
19
+ def delete
20
+ return if !exist?
46
21
 
47
- def save(uid, message)
48
- store.add(uid, message)
22
+ File.unlink(pathname)
49
23
  end
50
24
 
51
- def rename(new_name)
52
- @folder = new_name
53
- store.rename new_name
54
- end
25
+ def length
26
+ return nil if !exist?
55
27
 
56
- def update_uid(old, new)
57
- store.update_uid old, new
28
+ File.stat(pathname).size
58
29
  end
59
30
 
60
- private
61
-
62
- def store
63
- @store ||=
64
- begin
65
- create_containing_directory
66
- Serializer::MboxStore.new(path, folder)
67
- end
31
+ def pathname
32
+ "#{folder_path}.mbox"
68
33
  end
69
34
 
70
- def apply_new_uid_validity(value)
71
- digit = 0
72
- new_name = nil
73
- loop do
74
- extra = digit.zero? ? "" : "-#{digit}"
75
- new_name = "#{folder}-#{store.uid_validity}#{extra}"
76
- test_store = Serializer::MboxStore.new(path, new_name)
77
- break if !test_store.exist?
78
-
79
- digit += 1
35
+ def rename(new_path)
36
+ if exist?
37
+ old_pathname = pathname
38
+ @folder_path = new_path
39
+ File.rename(old_pathname, pathname)
40
+ else
41
+ @folder_path = new_path
80
42
  end
81
- rename_store new_name, value
82
- end
83
-
84
- def rename_store(new_name, value)
85
- store.rename new_name
86
- @store = nil
87
- store.uid_validity = value
88
- new_name
89
43
  end
90
44
 
91
- def relative_path
92
- File.dirname(folder)
93
- end
94
-
95
- def containing_directory
96
- File.join(path, relative_path)
45
+ def rewind(length)
46
+ File.open(pathname, File::RDWR | File::CREAT, 0o644) do |f|
47
+ f.truncate(length)
48
+ end
97
49
  end
98
50
 
99
- def full_path
100
- File.expand_path(containing_directory)
51
+ def touch
52
+ File.open(pathname, "a") {}
101
53
  end
102
54
 
103
- def create_containing_directory
104
- if !File.directory?(full_path)
105
- Utils.make_folder(
106
- path, relative_path, Serializer::DIRECTORY_PERMISSIONS
107
- )
108
- end
55
+ private
109
56
 
110
- if Utils.mode(full_path) !=
111
- Serializer::DIRECTORY_PERMISSIONS
112
- FileUtils.chmod Serializer::DIRECTORY_PERMISSIONS, full_path
113
- end
57
+ def exist?
58
+ File.exist?(pathname)
114
59
  end
115
60
  end
116
61
  end
@@ -1,4 +1,6 @@
1
1
  module Imap::Backup
2
+ class Serializer; end
3
+
2
4
  class Serializer::MboxEnumerator
3
5
  attr_reader :mbox_pathname
4
6
 
@@ -0,0 +1,29 @@
1
+ require "email/mboxrd/message"
2
+ require "imap/backup/serializer/mbox_enumerator"
3
+
4
+ module Imap::Backup
5
+ class Serializer::MessageEnumerator
6
+ attr_reader :imap
7
+ attr_reader :mbox
8
+
9
+ def initialize(imap:, mbox:)
10
+ @imap = imap
11
+ @mbox = mbox
12
+ end
13
+
14
+ def run(uids:)
15
+ indexes = uids.each.with_object({}) do |uid_maybe_string, acc|
16
+ uid = uid_maybe_string.to_i
17
+ index = imap.index(uid)
18
+ acc[index] = uid if index
19
+ end
20
+ enumerator = Serializer::MboxEnumerator.new(mbox.pathname)
21
+ enumerator.each.with_index do |raw, i|
22
+ uid = indexes[i]
23
+ next if !uid
24
+
25
+ yield uid, Email::Mboxrd::Message.from_serialized(raw)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ module Imap::Backup
2
+ class Serializer::UnusedNameFinder
3
+ attr_reader :serializer
4
+
5
+ def initialize(serializer:)
6
+ @serializer = serializer
7
+ end
8
+
9
+ def run
10
+ digit = 0
11
+ folder = nil
12
+
13
+ loop do
14
+ extra = digit.zero? ? "" : "-#{digit}"
15
+ folder = "#{serializer.folder}-#{serializer.uid_validity}#{extra}"
16
+ test = Serializer.new(serializer.path, folder)
17
+ break if !test.validate!
18
+
19
+ digit += 1
20
+ end
21
+
22
+ folder
23
+ end
24
+ end
25
+ end
@@ -1,6 +1,163 @@
1
+ require "forwardable"
2
+
3
+ require "email/mboxrd/message"
4
+ require "imap/backup/serializer/appender"
5
+ require "imap/backup/serializer/imap"
6
+ require "imap/backup/serializer/mbox"
7
+ require "imap/backup/serializer/mbox_enumerator"
8
+ require "imap/backup/serializer/message_enumerator"
9
+ require "imap/backup/serializer/unused_name_finder"
10
+
1
11
  module Imap::Backup
2
- module Serializer
3
- DIRECTORY_PERMISSIONS = 0o700
4
- FILE_PERMISSIONS = 0o600
12
+ class Serializer
13
+ def self.folder_path_for(path:, folder:)
14
+ relative = File.join(path, folder)
15
+ File.expand_path(relative)
16
+ end
17
+
18
+ extend Forwardable
19
+
20
+ def_delegator :mbox, :pathname, :mbox_pathname
21
+ def_delegators :imap, :uid_validity, :uids, :update_uid
22
+
23
+ attr_reader :folder
24
+ attr_reader :path
25
+
26
+ def initialize(path, folder)
27
+ @path = path
28
+ @folder = folder
29
+ end
30
+
31
+ # Returns true if there are existing, valid files
32
+ # false otherwise (in which case any existing files are deleted)
33
+ def validate!
34
+ return true if imap.valid? && mbox.valid?
35
+
36
+ imap.delete
37
+ @imap = nil
38
+ mbox.delete
39
+ @mbox = nil
40
+
41
+ false
42
+ end
43
+
44
+ def apply_uid_validity(value)
45
+ validate!
46
+
47
+ case
48
+ when uid_validity.nil?
49
+ internal_force_uid_validity(value)
50
+ nil
51
+ when uid_validity == value
52
+ # NOOP
53
+ nil
54
+ else
55
+ apply_new_uid_validity value
56
+ end
57
+ end
58
+
59
+ def force_uid_validity(value)
60
+ validate!
61
+
62
+ internal_force_uid_validity(value)
63
+ end
64
+
65
+ def append(uid, message)
66
+ validate!
67
+
68
+ appender = Serializer::Appender.new(folder: folder, imap: imap, mbox: mbox)
69
+ appender.run(uid: uid, message: message)
70
+ end
71
+
72
+ def load(uid_maybe_string)
73
+ validate!
74
+
75
+ uid = uid_maybe_string.to_i
76
+ message_index = imap.index(uid)
77
+ return nil if message_index.nil?
78
+
79
+ internal_load_nth(message_index)
80
+ end
81
+
82
+ def load_nth(index)
83
+ validate!
84
+
85
+ internal_load_nth(index)
86
+ end
87
+
88
+ def each_message(required_uids, &block)
89
+ validate!
90
+
91
+ return enum_for(:each_message, required_uids) if !block
92
+
93
+ enumerator = Serializer::MessageEnumerator.new(imap: imap, mbox: mbox)
94
+ enumerator.run(uids: required_uids, &block)
95
+ end
96
+
97
+ private
98
+
99
+ def rename(new_name)
100
+ destination = self.class.folder_path_for(path: path, folder: new_name)
101
+ ensure_containing_directory(new_name)
102
+ mbox.rename destination
103
+ imap.rename destination
104
+ end
105
+
106
+ def internal_force_uid_validity(value)
107
+ imap.uid_validity = value
108
+ mbox.touch
109
+ end
110
+
111
+ def internal_load_nth(index)
112
+ enumerator = Serializer::MboxEnumerator.new(mbox.pathname)
113
+ enumerator.each.with_index do |raw, i|
114
+ next if i != index
115
+
116
+ return Email::Mboxrd::Message.from_serialized(raw)
117
+ end
118
+ nil
119
+ end
120
+
121
+ def mbox
122
+ @mbox ||=
123
+ begin
124
+ ensure_containing_directory(folder)
125
+ Serializer::Mbox.new(folder_path)
126
+ end
127
+ end
128
+
129
+ def imap
130
+ @imap ||=
131
+ begin
132
+ ensure_containing_directory(folder)
133
+ Serializer::Imap.new(folder_path)
134
+ end
135
+ end
136
+
137
+ def folder_path
138
+ self.class.folder_path_for(path: path, folder: folder)
139
+ end
140
+
141
+ def ensure_containing_directory(folder)
142
+ relative = File.dirname(folder)
143
+ directory = Serializer::Directory.new(path, relative)
144
+ directory.ensure_exists
145
+ end
146
+
147
+ def apply_new_uid_validity(value)
148
+ new_name = rename_existing_folder
149
+ # Clear memoization so we get empty data
150
+ @mbox = nil
151
+ @imap = nil
152
+ internal_force_uid_validity(value)
153
+
154
+ new_name
155
+ end
156
+
157
+ def rename_existing_folder
158
+ new_name = Serializer::UnusedNameFinder.new(serializer: self).run
159
+ rename new_name
160
+ new_name
161
+ end
5
162
  end
6
163
  end
@@ -0,0 +1,75 @@
1
+ require "imap/backup/setup/helpers"
2
+
3
+ module Imap::Backup
4
+ class Setup; end
5
+ class Setup::Account; end
6
+
7
+ class Setup::Account::Header
8
+ attr_reader :account
9
+ attr_reader :menu
10
+
11
+ def initialize(menu:, account:)
12
+ @menu = menu
13
+ @account = account
14
+ end
15
+
16
+ def run
17
+ menu.header = <<~HEADER.chomp
18
+ #{helpers.title_prefix} Account#{modified_flag}
19
+
20
+ email #{space}#{account.username}
21
+ password#{space}#{masked_password}
22
+ path #{space}#{local_path}
23
+ folders #{space}#{folders.map { |f| f[:name] }.join(', ')}#{multi_fetch_size}
24
+ server #{space}#{account.server}#{connection_options}
25
+
26
+ Choose an action
27
+ HEADER
28
+ end
29
+
30
+ private
31
+
32
+ def folders
33
+ account.folders || []
34
+ end
35
+
36
+ def helpers
37
+ Setup::Helpers.new
38
+ end
39
+
40
+ def modified_flag
41
+ account.modified? ? "*" : ""
42
+ end
43
+
44
+ def multi_fetch_size
45
+ "\nmulti-fetch #{account.multi_fetch_size}" if account.multi_fetch_size > 1
46
+ end
47
+
48
+ def connection_options
49
+ return nil if !account.connection_options
50
+
51
+ escaped = JSON.generate(account.connection_options)
52
+ escaped.gsub!('"', '\"')
53
+ "\nconnection options '#{escaped}'"
54
+ end
55
+
56
+ def space
57
+ account.connection_options ? " " * 12 : " " * 4
58
+ end
59
+
60
+ def masked_password
61
+ if (account.password == "") || account.password.nil?
62
+ "(unset)"
63
+ else
64
+ account.password.gsub(/./, "x")
65
+ end
66
+ end
67
+
68
+ def local_path
69
+ # In order to handle backslashes, as Highline effectively
70
+ # does an eval (!) on its templates, we need to doubly
71
+ # escape them
72
+ account.local_path.gsub("\\", "\\\\\\\\")
73
+ end
74
+ end
75
+ end