imap-backup 5.2.0 → 6.0.0.rc2

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -2
  3. data/docs/development.md +10 -4
  4. data/lib/cli_coverage.rb +1 -1
  5. data/lib/imap/backup/account/connection.rb +7 -11
  6. data/lib/imap/backup/account.rb +31 -11
  7. data/lib/imap/backup/cli/folders.rb +3 -3
  8. data/lib/imap/backup/cli/migrate.rb +3 -3
  9. data/lib/imap/backup/cli/utils.rb +2 -2
  10. data/lib/imap/backup/configuration.rb +1 -11
  11. data/lib/imap/backup/downloader.rb +13 -9
  12. data/lib/imap/backup/serializer/directory.rb +37 -0
  13. data/lib/imap/backup/serializer/imap.rb +120 -0
  14. data/lib/imap/backup/serializer/mbox.rb +23 -94
  15. data/lib/imap/backup/serializer/mbox_enumerator.rb +2 -0
  16. data/lib/imap/backup/serializer.rb +180 -3
  17. data/lib/imap/backup/setup/account.rb +52 -29
  18. data/lib/imap/backup/setup/helpers.rb +1 -1
  19. data/lib/imap/backup/thunderbird/mailbox_exporter.rb +1 -1
  20. data/lib/imap/backup/version.rb +3 -3
  21. data/lib/imap/backup.rb +0 -1
  22. data/spec/features/backup_spec.rb +8 -16
  23. data/spec/features/support/aruba.rb +4 -3
  24. data/spec/unit/imap/backup/account/connection_spec.rb +36 -8
  25. data/spec/unit/imap/backup/account/folder_spec.rb +10 -0
  26. data/spec/unit/imap/backup/account_spec.rb +246 -0
  27. data/spec/unit/imap/backup/cli/accounts_spec.rb +12 -1
  28. data/spec/unit/imap/backup/cli/backup_spec.rb +19 -0
  29. data/spec/unit/imap/backup/cli/folders_spec.rb +39 -0
  30. data/spec/unit/imap/backup/cli/local_spec.rb +26 -7
  31. data/spec/unit/imap/backup/cli/migrate_spec.rb +80 -0
  32. data/spec/unit/imap/backup/cli/restore_spec.rb +67 -0
  33. data/spec/unit/imap/backup/cli/setup_spec.rb +17 -0
  34. data/spec/unit/imap/backup/cli/utils_spec.rb +68 -5
  35. data/spec/unit/imap/backup/cli_spec.rb +93 -0
  36. data/spec/unit/imap/backup/client/apple_mail_spec.rb +9 -0
  37. data/spec/unit/imap/backup/configuration_spec.rb +2 -2
  38. data/spec/unit/imap/backup/downloader_spec.rb +59 -7
  39. data/spec/unit/imap/backup/migrator_spec.rb +1 -1
  40. data/spec/unit/imap/backup/sanitizer_spec.rb +42 -0
  41. data/spec/unit/imap/backup/serializer/directory_spec.rb +37 -0
  42. data/spec/unit/imap/backup/serializer/imap_spec.rb +218 -0
  43. data/spec/unit/imap/backup/serializer/mbox_spec.rb +62 -183
  44. data/spec/unit/imap/backup/serializer_spec.rb +296 -0
  45. data/spec/unit/imap/backup/setup/account_spec.rb +120 -25
  46. data/spec/unit/imap/backup/setup/helpers_spec.rb +15 -0
  47. data/spec/unit/imap/backup/thunderbird/mailbox_exporter_spec.rb +116 -0
  48. data/spec/unit/imap/backup/uploader_spec.rb +1 -1
  49. data/spec/unit/retry_on_error_spec.rb +34 -0
  50. metadata +36 -7
  51. data/lib/imap/backup/serializer/mbox_store.rb +0 -217
  52. data/spec/unit/imap/backup/serializer/mbox_store_spec.rb +0 -329
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 164962561883b82372b32826843681c6a802e88982f4906778758a073e1479d8
4
- data.tar.gz: 8a71ad18952f675f61bc6342a3a9a60c2fc71797444142c2af5f98ef18ac13e9
3
+ metadata.gz: 4f9cac7966fac9858fdc380d8e9638c8d479035ee4053444a104b8c3eb5a304c
4
+ data.tar.gz: 5d9785b5e34d90535c8052d216d9b3dbf46ea743672f05129604729662f42f34
5
5
  SHA512:
6
- metadata.gz: 600ff4b72954cecfb2037b9ef12b16029248a6c6e27b2b801661b5c746264a8aed7a99aa689cbeecc8e89f571b9f4c26350669e582bc087a518981bceb677141
7
- data.tar.gz: b706026f0b8231f10dfbb0a0c39162ccdf6a8671c513b6cbf637352dcd8d99987a3f93d2b523db7f4a8e3ff7178877dc3d84c1ad506245a9773330abea2a792f
6
+ metadata.gz: a4595e4316dcdc20123a8ed2bec524d37c1de95da49281a30484349c317a996cf3c6946617a489880a0c41db41698b2ee862d9aee4f9c9b8fa34d67707ba924c
7
+ data.tar.gz: ccee268de9cb6225c72fe0cb11a2552fc793f664429d8dfa918e5b4697961c8e5e15e47ed653dac241f377385806443f17683614b8ff76075a8ff2f12e07f98c
data/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ ![Version](https://img.shields.io/gem/v/imap-backup?label=Version&logo=rubygems)
2
+ [![Build Status](https://github.com/joeyates/imap-backup/actions/workflows/main.yml/badge.svg)][CI Status]
3
+ ![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/joeyates/b54fe758bfb405c04bef72dad293d707/raw/coverage.json)
4
+ ![License](https://img.shields.io/github/license/joeyates/imap-backup?color=brightgreen&label=License)
5
+
1
6
  # imap-backup
2
7
 
3
8
  *Backup GMail (or other IMAP) accounts to disk*
@@ -5,10 +10,12 @@
5
10
  * [Source Code]
6
11
  * [API documentation]
7
12
  * [Rubygem]
13
+ * [CI Status]
8
14
 
9
15
  [Source Code]: https://github.com/joeyates/imap-backup "Source code at GitHub"
10
- [API documentation]: http://rubydoc.info/gems/imap-backup/frames "RDoc API Documentation at Rubydoc.info"
11
- [Rubygem]: http://rubygems.org/gems/imap-backup "Ruby gem at rubygems.org"
16
+ [API documentation]: https://rubydoc.info/gems/imap-backup/frames "RDoc API Documentation at Rubydoc.info"
17
+ [Rubygem]: https://rubygems.org/gems/imap-backup "Ruby gem at rubygems.org"
18
+ [CI Status]: https://github.com/joeyates/imap-backup/actions/workflows/main.yml
12
19
 
13
20
  # Installation
14
21
 
data/docs/development.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Testing
2
2
 
3
- ## Integration Tests
3
+ ## Feature Specs
4
4
 
5
- Integration tests (feature specs) are run against a local IMAP server
6
- controlled by Docker Compose, which needs to be started
7
- before running the test suite.
5
+ Specs under `specs/features` are integration specs run against a local IMAP server
6
+ controlled by Docker Compose.
7
+ Before running the test suite, it needs to be started:
8
8
 
9
9
  ```sh
10
10
  $ docker-compose up -d
@@ -26,6 +26,12 @@ or
26
26
  $ rspec --tag ~docker
27
27
  ```
28
28
 
29
+ ### Debugging
30
+
31
+ The feature specs are run 'out of process' via the Aruba gem.
32
+ In order to see debugging output from the process,
33
+ use `last_command_started.output`.
34
+
29
35
  ## Access Docker imap server
30
36
 
31
37
  ```ruby
data/lib/cli_coverage.rb CHANGED
@@ -4,7 +4,7 @@ class CliCoverage
4
4
  require "simplecov"
5
5
 
6
6
  # Collect coverage separately
7
- SimpleCov.command_name "#{ENV['COVERAGE']} coverage"
7
+ SimpleCov.command_name "#{ENV['COVERAGE']} #{ARGV.join(' ')} coverage"
8
8
 
9
9
  # Silence output
10
10
  SimpleCov.formatter = SimpleCov::Formatter::SimpleFormatter
@@ -1,5 +1,6 @@
1
1
  require "imap/backup/client/apple_mail"
2
2
  require "imap/backup/client/default"
3
+ require "imap/backup/serializer/directory"
3
4
 
4
5
  require "retry_on_error"
5
6
 
@@ -52,7 +53,7 @@ module Imap::Backup
52
53
  def status
53
54
  ensure_account_folder
54
55
  backup_folders.map do |folder|
55
- s = Serializer::Mbox.new(account.local_path, folder.name)
56
+ s = Serializer.new(account.local_path, folder.name)
56
57
  {name: folder.name, local: s.uids, remote: folder.uids}
57
58
  end
58
59
  end
@@ -69,7 +70,7 @@ module Imap::Backup
69
70
  serializer.apply_uid_validity(folder.uid_validity)
70
71
  begin
71
72
  Downloader.new(
72
- folder, serializer, block_size: config.download_block_size
73
+ folder, serializer, multi_fetch_size: account.multi_fetch_size
73
74
  ).run
74
75
  rescue Net::IMAP::ByeResponseError
75
76
  reconnect
@@ -86,7 +87,7 @@ module Imap::Backup
86
87
  base = Pathname.new(account.local_path)
87
88
  Pathname.glob(glob) do |path|
88
89
  name = path.relative_path_from(base).to_s[0..-6]
89
- serializer = Serializer::Mbox.new(account.local_path, name)
90
+ serializer = Serializer.new(account.local_path, name)
90
91
  folder = Account::Folder.new(self, name)
91
92
  yield serializer, folder
92
93
  end
@@ -110,7 +111,6 @@ module Imap::Backup
110
111
  def reset
111
112
  @backup_folders = nil
112
113
  @client = nil
113
- @config = nil
114
114
  @folder_names = nil
115
115
  @provider = nil
116
116
  @server = nil
@@ -144,7 +144,7 @@ module Imap::Backup
144
144
 
145
145
  def each_folder
146
146
  backup_folders.each do |folder|
147
- serializer = Serializer::Mbox.new(account.local_path, folder.name)
147
+ serializer = Serializer.new(account.local_path, folder.name)
148
148
  yield folder, serializer
149
149
  end
150
150
  end
@@ -161,7 +161,7 @@ module Imap::Backup
161
161
  Imap::Backup::Logger.logger.debug(
162
162
  "Backup '#{old_name}' renamed and restored to '#{new_name}'"
163
163
  )
164
- new_serializer = Serializer::Mbox.new(account.local_path, new_name)
164
+ new_serializer = Serializer.new(account.local_path, new_name)
165
165
  new_folder = Account::Folder.new(self, new_name)
166
166
  new_folder.create
167
167
  new_serializer.force_uid_validity(new_folder.uid_validity)
@@ -180,7 +180,7 @@ module Imap::Backup
180
180
  Utils.make_folder(
181
181
  File.dirname(account.local_path),
182
182
  File.basename(account.local_path),
183
- Serializer::DIRECTORY_PERMISSIONS
183
+ Serializer::Directory::DIRECTORY_PERMISSIONS
184
184
  )
185
185
  end
186
186
 
@@ -195,9 +195,5 @@ module Imap::Backup
195
195
  def provider_options
196
196
  provider.options.merge(account.connection_options || {})
197
197
  end
198
-
199
- def config
200
- @config ||= Configuration.new
201
- end
202
198
  end
203
199
  end
@@ -1,5 +1,7 @@
1
1
  module Imap::Backup
2
2
  class Account
3
+ DEFAULT_MULTI_FETCH_SIZE = 1
4
+
3
5
  attr_reader :username
4
6
  attr_reader :password
5
7
  attr_reader :local_path
@@ -7,7 +9,6 @@ module Imap::Backup
7
9
  attr_reader :server
8
10
  attr_reader :connection_options
9
11
  attr_reader :changes
10
- attr_reader :marked_for_deletion
11
12
 
12
13
  def initialize(options)
13
14
  @username = options[:username]
@@ -16,6 +17,7 @@ module Imap::Backup
16
17
  @folders = options[:folders]
17
18
  @server = options[:server]
18
19
  @connection_options = options[:connection_options]
20
+ @multi_fetch_size = options[:multi_fetch_size]
19
21
  @changes = {}
20
22
  @marked_for_deletion = false
21
23
  end
@@ -25,18 +27,18 @@ module Imap::Backup
25
27
  end
26
28
 
27
29
  def valid?
28
- username && password
30
+ username && password ? true : false
29
31
  end
30
32
 
31
33
  def modified?
32
34
  changes.any?
33
35
  end
34
36
 
35
- def clear_changes!
37
+ def clear_changes
36
38
  @changes = {}
37
39
  end
38
40
 
39
- def mark_for_deletion!
41
+ def mark_for_deletion
40
42
  @marked_for_deletion = true
41
43
  end
42
44
 
@@ -53,6 +55,7 @@ module Imap::Backup
53
55
  h[:folders] = @folders if @folders
54
56
  h[:server] = @server if @server
55
57
  h[:connection_options] = @connection_options if @connection_options
58
+ h[:multi_fetch_size] = multi_fetch_size if @multi_fetch_size
56
59
  h
57
60
  end
58
61
 
@@ -82,20 +85,37 @@ module Imap::Backup
82
85
  update(:connection_options, parsed)
83
86
  end
84
87
 
88
+ def multi_fetch_size
89
+ int = @multi_fetch_size.to_i
90
+ if int.positive?
91
+ int
92
+ else
93
+ DEFAULT_MULTI_FETCH_SIZE
94
+ end
95
+ end
96
+
97
+ def multi_fetch_size=(value)
98
+ parsed = value.to_i
99
+ parsed = DEFAULT_MULTI_FETCH_SIZE if !parsed.positive?
100
+ update(:multi_fetch_size, parsed)
101
+ end
102
+
85
103
  private
86
104
 
87
105
  def update(field, value)
106
+ key = :"@#{field}"
88
107
  if changes[field]
89
108
  change = changes[field]
90
- changes.delete(field) if change[:from] == value
109
+ if change[:from] == value
110
+ changes.delete(field)
111
+ else
112
+ change[:to] = value
113
+ end
114
+ else
115
+ current = instance_variable_get(key)
116
+ changes[field] = {from: current, to: value}
91
117
  end
92
- set_field!(field, value)
93
- end
94
118
 
95
- def set_field!(field, value)
96
- key = :"@#{field}"
97
- current = instance_variable_get(key)
98
- changes[field] = {from: current, to: value}
99
119
  instance_variable_set(key, value)
100
120
  end
101
121
  end
@@ -13,15 +13,15 @@ module Imap::Backup
13
13
  no_commands do
14
14
  def run
15
15
  each_connection(account_names) do |connection|
16
- puts connection.account.username
16
+ Kernel.puts connection.account.username
17
17
  # TODO: Make folder_names private once this command
18
18
  # has been removed.
19
19
  folders = connection.folder_names
20
20
  if folders.nil?
21
- warn "Unable to list account folders"
21
+ Kernel.warn "Unable to list account folders"
22
22
  return false
23
23
  end
24
- folders.each { |f| puts "\t#{f}" }
24
+ folders.each { |f| Kernel.puts "\t#{f}" }
25
25
  end
26
26
  end
27
27
  end
@@ -39,11 +39,11 @@ module Imap::Backup
39
39
  end
40
40
 
41
41
  if !destination_account
42
- raise "Account #{destination_email} does not exist"
42
+ raise "Account '#{destination_email}' does not exist"
43
43
  end
44
44
 
45
45
  if !source_account
46
- raise "Account #{source_email} does not exist"
46
+ raise "Account '#{source_email}' does not exist"
47
47
  end
48
48
  end
49
49
 
@@ -61,7 +61,7 @@ module Imap::Backup
61
61
  glob = File.join(source_local_path, "**", "*.imap")
62
62
  Pathname.glob(glob) do |path|
63
63
  name = source_folder_name(path)
64
- serializer = Serializer::Mbox.new(source_local_path, name)
64
+ serializer = Serializer.new(source_local_path, name)
65
65
  folder = folder_for(name)
66
66
  yield serializer, folder
67
67
  end
@@ -14,7 +14,7 @@ module Imap::Backup
14
14
  connection.backup_folders.each do |folder|
15
15
  next if !folder.exist?
16
16
 
17
- serializer = Serializer::Mbox.new(connection.account.local_path, folder.name)
17
+ serializer = Serializer.new(connection.account.local_path, folder.name)
18
18
  do_ignore_folder_history(folder, serializer)
19
19
  end
20
20
  end
@@ -75,7 +75,7 @@ module Imap::Backup
75
75
  Skipped #{uid}
76
76
  MESSAGE
77
77
 
78
- serializer.save(uid, message)
78
+ serializer.append uid, message
79
79
  end
80
80
  end
81
81
 
@@ -6,7 +6,6 @@ require "imap/backup/account"
6
6
  module Imap::Backup
7
7
  class Configuration
8
8
  CONFIGURATION_DIRECTORY = File.expand_path("~/.imap-backup")
9
- DEFAULT_DOWNLOAD_BLOCK_SIZE = 1
10
9
  VERSION = "2.0"
11
10
 
12
11
  attr_reader :pathname
@@ -52,15 +51,6 @@ module Imap::Backup
52
51
  end
53
52
  end
54
53
 
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
62
- end
63
-
64
54
  def modified?
65
55
  ensure_loaded!
66
56
  return true if @saved_debug != @debug
@@ -103,7 +93,7 @@ module Imap::Backup
103
93
  end
104
94
 
105
95
  def remove_modified_flags
106
- accounts.each { |a| a.clear_changes! }
96
+ accounts.each { |a| a.clear_changes }
107
97
  end
108
98
 
109
99
  def remove_deleted_accounts
@@ -1,33 +1,34 @@
1
1
  module Imap::Backup
2
+ class MultiFetchFailedError < StandardError; end
3
+
2
4
  class Downloader
3
5
  attr_reader :folder
4
6
  attr_reader :serializer
5
- attr_reader :block_size
7
+ attr_reader :multi_fetch_size
6
8
 
7
- def initialize(folder, serializer, block_size: 1)
9
+ def initialize(folder, serializer, multi_fetch_size: 1)
8
10
  @folder = folder
9
11
  @serializer = serializer
10
- @block_size = block_size
12
+ @multi_fetch_size = multi_fetch_size
11
13
  end
12
14
 
13
15
  def run
14
16
  uids = folder.uids - serializer.uids
15
17
  count = uids.count
16
18
  debug "#{count} new messages"
17
- uids.each_slice(block_size).with_index do |block, i|
19
+ uids.each_slice(multi_fetch_size).with_index do |block, i|
18
20
  uids_and_bodies = folder.fetch_multi(block)
19
21
  if uids_and_bodies.nil?
20
- if block_size > 1
22
+ if multi_fetch_size > 1
21
23
  debug("Multi fetch failed for UIDs #{block.join(", ")}, switching to single fetches")
22
- @block_size = 1
23
- redo
24
+ raise MultiFetchFailedError
24
25
  else
25
26
  debug("Fetch failed for UID #{block[0]} - skipping")
26
27
  next
27
28
  end
28
29
  end
29
30
 
30
- offset = i * block_size + 1
31
+ offset = i * multi_fetch_size + 1
31
32
  uids_and_bodies.each.with_index do |uid_and_body, j|
32
33
  uid = uid_and_body[:uid]
33
34
  body = uid_and_body[:body]
@@ -38,10 +39,13 @@ module Imap::Backup
38
39
  info("Fetch returned empty UID - skipping")
39
40
  else
40
41
  debug("uid: #{uid} (#{offset + j}/#{count}) - #{body.size} bytes")
41
- serializer.save(uid, body)
42
+ serializer.append uid, body
42
43
  end
43
44
  end
44
45
  end
46
+ rescue MultiFetchFailedError
47
+ @multi_fetch_size = 1
48
+ retry
45
49
  end
46
50
 
47
51
  private
@@ -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,120 @@
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
+ end
16
+
17
+ def append(uid)
18
+ uids << uid
19
+ save
20
+ end
21
+
22
+ def exist?
23
+ File.exist?(pathname)
24
+ end
25
+
26
+ def include?(uid)
27
+ uids.include?(uid)
28
+ end
29
+
30
+ def index(uid)
31
+ uids.find_index(uid)
32
+ end
33
+
34
+ def rename(new_path)
35
+ if exist?
36
+ old_pathname = pathname
37
+ @folder_path = new_path
38
+ File.rename(old_pathname, pathname)
39
+ else
40
+ @folder_path = new_path
41
+ end
42
+ end
43
+
44
+ def uid_validity
45
+ ensure_loaded
46
+ @uid_validity
47
+ end
48
+
49
+ def uid_validity=(value)
50
+ ensure_loaded
51
+ @uid_validity = value
52
+ @uids ||= []
53
+ save
54
+ end
55
+
56
+ def uids
57
+ ensure_loaded
58
+ @uids || []
59
+ end
60
+
61
+ def update_uid(old, new)
62
+ index = uids.find_index(old.to_i)
63
+ return if index.nil?
64
+
65
+ uids[index] = new.to_i
66
+ save
67
+ end
68
+
69
+ private
70
+
71
+ def pathname
72
+ "#{folder_path}.imap"
73
+ end
74
+
75
+ def ensure_loaded
76
+ return if loaded
77
+
78
+ data = load
79
+ if data
80
+ @uids = data[:uids].map(&:to_i)
81
+ @uid_validity = data[:uid_validity]
82
+ else
83
+ @uids = []
84
+ @uid_validity = nil
85
+ end
86
+ @loaded = true
87
+ end
88
+
89
+ def load
90
+ return nil if !exist?
91
+
92
+ data = nil
93
+ begin
94
+ content = File.read(pathname)
95
+ data = JSON.parse(content, symbolize_names: true)
96
+ rescue JSON::ParserError
97
+ return nil
98
+ end
99
+
100
+ return nil if !data.key?(:uids)
101
+ return nil if !data[:uids].is_a?(Array)
102
+
103
+ data
104
+ end
105
+
106
+ def save
107
+ ensure_loaded
108
+
109
+ raise "Cannot save metadata without a uid_validity" if !uid_validity
110
+
111
+ data = {
112
+ version: CURRENT_VERSION,
113
+ uid_validity: @uid_validity,
114
+ uids: @uids
115
+ }
116
+ content = data.to_json
117
+ File.open(pathname, "w") { |f| f.write content }
118
+ end
119
+ end
120
+ end