imap-backup 11.1.0 → 12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdcbbac2458c28626fdf8be328db6f949be5cf981fde3438df03356b28849ebf
4
- data.tar.gz: 1ed5fc6db436f57c30e03a5036a61c2fa21853cd2d7ae08b861b491b04b6d12b
3
+ metadata.gz: ed7a9fd869f7b1ea66f5dc0816a4ce35d77c150bcf8170e2d778e893fda43e9d
4
+ data.tar.gz: e75c535f849eb326906c1965a1e86507676209cf41cae5b98ad62e894912d9b8
5
5
  SHA512:
6
- metadata.gz: a60997a5fdee3274d726e54c413c7b0e38df97246f4619358128a6786f5a148414d7050a6d498b805cb5376fca3d555f232e90c10d15d092ea49dce6007272e7
7
- data.tar.gz: 41b3038e096c34486670e423e6231cb4dd6eb8c39b035f8ce7580fb406bc6210ba8e436a51d51c61e67f9b5c3af3a971ad6ce016e1904850bdb14bc52e150f9c
6
+ metadata.gz: 7ced0838fae828c771e831441fc3caf71b0465f4da630c3580e1ff842c687e51ad92d7e1913e7f8d48e9bb2c2db2eeee60eea3e2dcb464fda2b51ffc9252d8ea
7
+ data.tar.gz: d751a6fb0e2eebc107f539cb80609d353bb0c07126fb09e5001bea69476ca9eaa5f1ffce7e6bab27f7efdc149be213997d20c741cc2b60bf3ccf6a3fd0bd810c
data/README.md CHANGED
@@ -113,7 +113,7 @@ These are activated via two settings:
113
113
  As with all performance tweaks, there are trade-offs.
114
114
  If you are using a small virtual server or Raspberry Pi
115
115
  to run your backups, you will probably want to leave
116
- the deafult settings.
116
+ the default settings.
117
117
  If, on the other hand, you are using a computer with a
118
118
  fair bit of RAM, and you are dealing with a *lot* of email,
119
119
  then changing these settings may be worthwhile.
@@ -122,16 +122,17 @@ then changing these settings may be worthwhile.
122
122
 
123
123
  This setting affects all account backups.
124
124
 
125
- When not set, each message is written to disk, one at a time.
126
- Doing so means the message itself is appended to the MBox file,
127
- but more importantly, the JSON metadata is rewritten to disk
128
- from scratch.
125
+ By default, `imap-backup` uses the "delay metadata" strategy.
126
+ As messages are being backed-up, the message *text*
127
+ is written to disk, while the related metadata is stored in memory.
129
128
 
130
- When in use, all of a mailboxes unbackupped messages are
131
- downloaded first, and then written to disk just once.
129
+ While this uses a little more memory, it avoids rewiting a growing JSON
130
+ file for every message, speeding things up and reducing disk wear.
132
131
 
133
- This speeds up backup as the metadata file is not rewritten
134
- after each message is added, but it potentially uses much more memory.
132
+ The alternative strategy, called "direct", writes everything to disk
133
+ as it is received. This method is slower, but has the advantage
134
+ of using slightly less memory, which may be important on very
135
+ resource-limited systems, like Raspberry Pis.
135
136
 
136
137
  ## Multi-fetch Size
137
138
 
@@ -140,12 +141,11 @@ By default, during backup, each message is downloaded one-by-one.
140
141
  Using this setting, you can download chunks of emails at a time,
141
142
  potentially speeding up the process.
142
143
 
143
- If you're not using "Delayed downlaod writes",
144
- using multi-fetch *will* mean that the backup process will use
145
- more memory - equivalent to the size of the greater number
146
- of messages downloaded at a time.
144
+ Using multi-fetch *will* mean that the backup process will use
145
+ more memory - equivalent to the size of the groups of messages
146
+ that are downloaded.
147
147
 
148
- This behaviour may also exceed limits on your email provider,
148
+ This behaviour may also exceed the rate limits on your email provider,
149
149
  so it's best to check before cranking it up!
150
150
 
151
151
  # Troubleshooting
data/docs/development.md CHANGED
@@ -6,25 +6,26 @@
6
6
 
7
7
  # Development
8
8
 
9
- A setup for developing under any available Ruby version is
10
- available in the container directory.
9
+ A Dockerfile is available to allow testing with all available Ruby versions,
10
+ see the `dev/container` directory.
11
11
 
12
12
  # Testing
13
13
 
14
14
  ## Feature Specs
15
15
 
16
- Specs under `specs/features` are integration specs run against a local IMAP server
17
- controlled by Docker Compose.
18
- Before running the test suite, it needs to be started:
16
+ Specs under `specs/features` are integration specs run against
17
+ two local IMAP servers controlled by Docker Compose.
18
+
19
+ Start them before running the test suite
19
20
 
20
21
  ```sh
21
- $ docker-compose up -d
22
+ $ docker-compose -f dev/docker-compose.yml up -d
22
23
  ```
23
24
 
24
25
  or, with Podman
25
26
 
26
27
  ```sh
27
- $ podman-compose -f docker-compose.yml up -d
28
+ $ podman-compose -f dev/docker-compose.yml up -d
28
29
  ```
29
30
 
30
31
  ```sh
@@ -62,24 +63,23 @@ use `last_command_started.output`.
62
63
 
63
64
  ```ruby
64
65
  require "net/imap"
66
+ require_relative "spec/features/support/30_email_server_helpers"
65
67
 
66
- imap = Net::IMAP.new("localhost", {port: 8993, ssl: {verify_mode: 0}})
67
- username = "address@example.com"
68
- imap.login(username, "pass")
68
+ include EmailServerHelpers
69
69
 
70
- message = "From: #{username}\nSubject: Some Subject\n\nHello!\n"
71
- response = imap.append("INBOX", message, nil, nil)
70
+ test_connection = test_server_connection_parameters
72
71
 
73
- imap.examine("INBOX")
74
- uids = imap.uid_search(["ALL"]).sort
72
+ test_imap = Net::IMAP.new(test_connection[:server], test_connection[:connection_options])
73
+ test_imap.login(test_connection[:username], test_connection[:password])
75
74
 
76
- fetch_data_items = imap.uid_fetch(uids, ["BODY[]"])
77
- ```
75
+ message = "From: #{test_connection[:username]}\nSubject: Some Subject\n\nHello!\n"
76
+ response = test_imap.append("INBOX", message, nil, nil)
78
77
 
79
- # Older Ruby Versions
78
+ test_imap.examine("INBOX")
79
+ uids = test_imap.uid_search(["ALL"]).sort
80
80
 
81
- Dockerfiles are available for all the supported Ruby versions,
82
- see the `container` directory.
81
+ fetch_data_items = test_imap.uid_fetch(uids, ["BODY[]"])
82
+ ```
83
83
 
84
84
  # Contributing
85
85
 
@@ -1,29 +1,47 @@
1
1
  # Migrate to a new e-mail server while keeping your existing address
2
2
 
3
- While switching e-mail provider (from provider `A` to `B`), you might want to keep the same address (`mymail@domain.com`), and restrieve all your existing e-mails on your new server `B`. `imap-backup` can do that too!
3
+ While switching e-mail provider (from provider `A` to `B`),
4
+ you might want to keep the same address (`mymail@domain.com`),
5
+ and copy all your existing e-mails to your new server `B`.
6
+ `imap-backup` can do that too!
4
7
 
5
- 1. Backup your e-mails: use [`imap-backup setup`](/docs/commands/setup.md) to setup connection to your old provider `A`, then launch [`imap-backup backup`](/docs/commands/backup.md).
8
+ It is best to use [`imap-backup migrate`](/docs/commands/migrate.md)
9
+ and not [`imap-backup restore`](/docs/commands/restore.md) here because
10
+ `migrate` simply copies emails to folders with the same name as the ones
11
+ they were downloaded from, while `restore` changes the names of restored
12
+ folders if folders with the same name already exist on the destination server.
13
+
14
+ 1. Backup your e-mails: use [`imap-backup setup`](/docs/commands/setup.md)
15
+ to setup connection to your old provider `A`,
16
+ then launch [`imap-backup backup`](/docs/commands/backup.md).
6
17
  1. Actually switch your e-mail service provider (update your DNS MX and all that...).
7
- 1. It is best to use [`imap-backup migrate`](/docs/commands/migrate.md) and not [`imap-backup restore`](/docs/commands/restore.md) here, but both the source and the destination have the same address... You need to manually rename your old account first:
18
+ 1. As both the source and the destination have the same address,
19
+ you need to manually rename your old account first:
8
20
 
9
- 1. Modify your configuration file manually (i.e. not via `imap-backup setup`) and rename your account to `mymail-old@domain.com`:
21
+ 1. Modify your configuration file manually
22
+ (i.e. not via `imap-backup setup`) and
23
+ rename your account to `mymail-old@domain.com`:
10
24
 
11
- ```diff
12
- "accounts": [
13
- {
14
- - "username": "mymail@domain.com",
15
- + "username": "mymail-old@domain.com",
16
- "password": "...",
17
- - "local_path": "/some/path/.imap-backup/mymail_domain.com",
18
- + "local_path": "/some/path/.imap-backup/mymail-old_domain.com",
19
- "folders": [...],
20
- "server": "..."
21
- }
22
- ```
25
+ ```diff
26
+ "accounts": [
27
+ {
28
+ - "username": "mymail@domain.com",
29
+ + "username": "mymail-old@domain.com",
30
+ "password": "...",
31
+ - "local_path": "/some/path/.imap-backup/mymail_domain.com",
32
+ + "local_path": "/some/path/.imap-backup/mymail-old_domain.com",
33
+ "folders": [...],
34
+ "server": "..."
35
+ }
36
+ ```
23
37
 
24
- 1. Rename the backup directory from `mymail_domain.com` to `mymail-old_domain.com`.
38
+ 1. Rename the backup directory from `mymail_domain.com`
39
+ to `mymail-old_domain.com`.
25
40
 
26
- 1. Set up a new account giving access to the new provider `B` using `imap-backup setup`.
27
- 1. Now you can use `imap-backup migrate`, optionnally adapting [delimiters and prefixes configuration](/docs/delimiters-and-prefixes.md) if need be:
41
+ 1. Set up a new account giving access to the new provider `B`
42
+ using `imap-backup setup`.
43
+ 1. Now you can use `imap-backup migrate`, optionally adapting
44
+ [delimiters and prefixes configuration](/docs/delimiters-and-prefixes.md)
45
+ if need be:
28
46
 
29
- imap-backup migrate mymail-old@domain.com mymail@domain.com [options]
47
+ imap-backup migrate mymail-old@domain.com mymail@domain.com [options]
@@ -20,7 +20,7 @@ module Imap::Backup
20
20
  def run
21
21
  Logger.logger.info "Running backup of account: #{account.username}"
22
22
  # start the connection so we get logging messages in the right order
23
- account.client
23
+ account.client.login
24
24
 
25
25
  Account::FolderEnsurer.new(account: account).run
26
26
  Account::LocalOnlyFolderDeleter.new(account: account).run if account.mirror_mode
@@ -2,8 +2,8 @@ require "socket"
2
2
 
3
3
  require "email/provider"
4
4
  require "imap/backup/client/apple_mail"
5
+ require "imap/backup/client/automatic_login_wrapper"
5
6
  require "imap/backup/client/default"
6
- require "retry_on_error"
7
7
 
8
8
  module Imap; end
9
9
 
@@ -11,10 +11,6 @@ module Imap::Backup
11
11
  class Account; end
12
12
 
13
13
  class Account::ClientFactory
14
- include RetryOnError
15
-
16
- LOGIN_RETRY_CLASSES = [::EOFError, ::Errno::ECONNRESET, ::SocketError].freeze
17
-
18
14
  attr_reader :account
19
15
 
20
16
  def initialize(account:)
@@ -24,20 +20,17 @@ module Imap::Backup
24
20
  end
25
21
 
26
22
  def run
27
- retry_on_error(errors: LOGIN_RETRY_CLASSES) do
28
- options = provider_options
29
- Logger.logger.debug(
30
- "Creating IMAP instance: #{server}, options: #{options.inspect}"
31
- )
32
- client =
33
- if provider.is_a?(Email::Provider::AppleMail)
34
- Client::AppleMail.new(server, account, options)
35
- else
36
- Client::Default.new(server, account, options)
37
- end
38
- client.login
39
- client
40
- end
23
+ options = provider_options
24
+ Logger.logger.debug(
25
+ "Creating IMAP instance: #{server}, options: #{options.inspect}"
26
+ )
27
+ client =
28
+ if provider.is_a?(Email::Provider::AppleMail)
29
+ Client::AppleMail.new(server, account, options)
30
+ else
31
+ Client::Default.new(server, account, options)
32
+ end
33
+ Client::AutomaticLoginWrapper.new(client: client)
41
34
  end
42
35
 
43
36
  private
@@ -30,7 +30,7 @@ module Imap::Backup
30
30
  Pathname.glob(glob) do |path|
31
31
  name = source_folder_name(path)
32
32
  serializer = Serializer.new(source_local_path, name)
33
- folder = destination_folder_for(name)
33
+ folder = destination_folder_for_path(name)
34
34
  yield serializer, folder
35
35
  end
36
36
  end
@@ -55,8 +55,8 @@ module Imap::Backup
55
55
  end
56
56
  end
57
57
 
58
- def destination_folder_for(name)
59
- parts = name.split(source_delimiter)
58
+ def destination_folder_for_path(name)
59
+ parts = name.split("/")
60
60
  no_source_prefix =
61
61
  if source_prefix_clipped != "" && parts.first == source_prefix_clipped
62
62
  parts[1..]
@@ -35,6 +35,7 @@ module Imap::Backup
35
35
  end
36
36
 
37
37
  def stats
38
+ Logger.logger.debug("[Stats] loading configuration")
38
39
  config = load_config(**options)
39
40
  account = account(config, email)
40
41
 
@@ -46,6 +47,7 @@ module Imap::Backup
46
47
 
47
48
  serializer = Serializer.new(account.local_path, folder.name)
48
49
  local_uids = serializer.uids
50
+ Logger.logger.debug("[Stats] fetching email list for '#{folder.name}'")
49
51
  remote_uids = folder.uids
50
52
  {
51
53
  folder: folder.name,
@@ -0,0 +1,163 @@
1
+ require "imap/backup/cli/backup"
2
+ require "imap/backup/cli/helpers"
3
+ require "imap/backup/cli/folder_enumerator"
4
+ require "imap/backup/migrator"
5
+ require "imap/backup/mirror"
6
+
7
+ module Imap; end
8
+
9
+ module Imap::Backup
10
+ class CLI::Transfer < Thor
11
+ include Thor::Actions
12
+ include CLI::Helpers
13
+
14
+ ACTIONS = %i(migrate mirror).freeze
15
+
16
+ attr_reader :action
17
+ attr_accessor :automatic_namespaces
18
+ attr_accessor :config_path
19
+ attr_accessor :destination_delimiter
20
+ attr_reader :destination_email
21
+ attr_accessor :destination_prefix
22
+ attr_reader :options
23
+ attr_accessor :reset
24
+ attr_accessor :source_delimiter
25
+ attr_reader :source_email
26
+ attr_accessor :source_prefix
27
+
28
+ def initialize(action, source_email, destination_email, options)
29
+ super([])
30
+ @action = action
31
+ @source_email = source_email
32
+ @destination_email = destination_email
33
+ @options = options
34
+ @automatic_namespaces = nil
35
+ @config_path = nil
36
+ @destination_delimiter = nil
37
+ @destination_prefix = nil
38
+ @reset = nil
39
+ @source_delimiter = nil
40
+ @source_prefix = nil
41
+ end
42
+
43
+ no_commands do
44
+ def run
45
+ raise "Unknown action '#{action}'" if !ACTIONS.include?(action)
46
+
47
+ process_options!
48
+ prepare_mirror if action == :mirror
49
+
50
+ folders.each do |serializer, folder|
51
+ case action
52
+ when :migrate
53
+ Migrator.new(serializer, folder, reset: reset).run
54
+ when :mirror
55
+ Mirror.new(serializer, folder).run
56
+ end
57
+ end
58
+ end
59
+
60
+ def process_options!
61
+ self.automatic_namespaces = options[:automatic_namespaces] || false
62
+ self.config_path = options[:config]
63
+ self.destination_delimiter = options[:destination_delimiter]
64
+ self.destination_prefix = options[:destination_prefix]
65
+ self.source_delimiter = options[:source_delimiter]
66
+ self.source_prefix = options[:source_prefix]
67
+ self.reset = options[:reset] || false
68
+ check_accounts!
69
+ choose_prefixes_and_delimiters!
70
+ end
71
+
72
+ def check_accounts!
73
+ if destination_email == source_email
74
+ raise "Source and destination accounts cannot be the same!"
75
+ end
76
+
77
+ raise "Account '#{destination_email}' does not exist" if !destination_account
78
+
79
+ raise "Account '#{source_email}' does not exist" if !source_account
80
+ end
81
+
82
+ def choose_prefixes_and_delimiters!
83
+ if automatic_namespaces
84
+ ensure_no_prefix_or_delimiter_parameters!
85
+ query_servers_for_settings
86
+ else
87
+ add_prefix_and_delimiter_defaults
88
+ end
89
+ end
90
+
91
+ def ensure_no_prefix_or_delimiter_parameters!
92
+ if destination_delimiter
93
+ raise "--automatic-namespaces is incompatible with --destination-delimiter"
94
+ end
95
+ if destination_prefix
96
+ raise "--automatic-namespaces is incompatible with --destination-prefix"
97
+ end
98
+ raise "--automatic-namespaces is incompatible with --source-delimiter" if source_delimiter
99
+ raise "--automatic-namespaces is incompatible with --source-prefix" if source_prefix
100
+ end
101
+
102
+ def query_servers_for_settings
103
+ self.destination_prefix, self.destination_delimiter = account_settings(destination_account)
104
+ self.source_prefix, self.source_delimiter = account_settings(source_account)
105
+ end
106
+
107
+ def account_settings(account)
108
+ namespaces = account.client.namespace
109
+ personal = namespaces.personal.first
110
+ [personal.prefix, personal.delim]
111
+ end
112
+
113
+ def add_prefix_and_delimiter_defaults
114
+ self.destination_delimiter ||= "/"
115
+ self.destination_prefix ||= ""
116
+ self.source_delimiter ||= "/"
117
+ self.source_prefix ||= ""
118
+ end
119
+
120
+ def prepare_mirror
121
+ warn_if_source_account_is_not_in_mirror_mode
122
+
123
+ CLI::Backup.new(config: config_path, accounts: source_email).run
124
+ end
125
+
126
+ def warn_if_source_account_is_not_in_mirror_mode
127
+ return if source_account.mirror_mode
128
+
129
+ message =
130
+ "The account '#{source_account.username}' " \
131
+ "is not set up to make mirror backups"
132
+ Logger.logger.warn message
133
+ end
134
+
135
+ def config
136
+ @config ||= load_config(config: config_path)
137
+ end
138
+
139
+ def enumerator_options
140
+ {
141
+ destination: destination_account,
142
+ destination_delimiter: destination_delimiter,
143
+ destination_prefix: destination_prefix,
144
+ source: source_account,
145
+ source_delimiter: source_delimiter,
146
+ source_prefix: source_prefix
147
+ }
148
+ end
149
+
150
+ def folders
151
+ CLI::FolderEnumerator.new(**enumerator_options)
152
+ end
153
+
154
+ def destination_account
155
+ config.accounts.find { |a| a.username == destination_email }
156
+ end
157
+
158
+ def source_account
159
+ config.accounts.find { |a| a.username == source_email }
160
+ end
161
+ end
162
+ end
163
+ end
@@ -10,18 +10,38 @@ module Imap::Backup
10
10
  autoload :Backup, "imap/backup/cli/backup"
11
11
  autoload :Folders, "imap/backup/cli/folders"
12
12
  autoload :Local, "imap/backup/cli/local"
13
- autoload :Migrate, "imap/backup/cli/migrate"
14
- autoload :Mirror, "imap/backup/cli/mirror"
15
13
  autoload :Remote, "imap/backup/cli/remote"
16
14
  autoload :Restore, "imap/backup/cli/restore"
17
15
  autoload :Setup, "imap/backup/cli/setup"
18
16
  autoload :Stats, "imap/backup/cli/stats"
17
+ autoload :Transfer, "imap/backup/cli/transfer"
19
18
  autoload :Utils, "imap/backup/cli/utils"
20
19
 
21
20
  include Helpers
22
21
 
23
22
  VERSION_ARGUMENTS = %w(-v --version).freeze
24
23
 
24
+ NAMESPACE_CONFIGURATION_DESCRIPTION = <<~DESC.freeze
25
+ Some IMAP servers use namespaces (i.e. prefixes like "INBOX"),
26
+ while others, while others concatenate the names of subfolders
27
+ with a charater ("delimiter") other than "/".
28
+
29
+ In these cases there are two choices.
30
+
31
+ You can use the `--automatic-namespaces` option.
32
+ This wil query the source and detination servers for their
33
+ namespace configuration and will adapt paths accordingly.
34
+ This option requires that both the source and destination
35
+ servers are available and work with the provided parameters
36
+ and authentication.
37
+
38
+ If automatic configuration does not work as desired, there are the
39
+ `--source-prefix=`, `--source-delimiter=`,
40
+ `--destination-prefix=` and `--destination-delimiter=` parameters.
41
+ To check what values you should use, check the output of the
42
+ `imap-backup remote namespaces EMAIL` command.
43
+ DESC
44
+
25
45
  default_task :backup
26
46
 
27
47
  def self.start(*args)
@@ -83,19 +103,22 @@ module Imap::Backup
83
103
  All emails which have been backed up for the "source account" (SOURCE_EMAIL) are
84
104
  uploaded to the "destination account" (DESTINATION_EMAIL).
85
105
 
86
- When one or other account has namespaces (i.e. prefixes like "INBOX"),
87
- use the `--source-prefix=` and/or `--destination-prefix=` options.
106
+ Some configuration may be necessary, as follows:
88
107
 
89
- When one or other account uses a delimiter other than `/` (i.e. `.`),
90
- use the `--source-delimiter=` and/or `--destination-delimiter=` options.
108
+ #{NAMESPACE_CONFIGURATION_DESCRIPTION}
91
109
 
92
- If you you want to delete existing emails in destination folders,
110
+ Finally, if you want to delete existing emails in destination folders,
93
111
  use the `--reset` option. In this case, all existing emails are
94
112
  deleted before uploading the migrated emails.
95
113
  DESC
96
114
  config_option
97
115
  quiet_option
98
116
  verbose_option
117
+ method_option(
118
+ "automatic-namespaces",
119
+ type: :boolean,
120
+ desc: "automatically choose delimiters and prefixes"
121
+ )
99
122
  method_option(
100
123
  "destination-delimiter",
101
124
  type: :string,
@@ -126,7 +149,7 @@ module Imap::Backup
126
149
  )
127
150
  def migrate(source_email, destination_email)
128
151
  non_logging_options = Imap::Backup::Logger.setup_logging(options)
129
- Migrate.new(source_email, destination_email, **non_logging_options).run
152
+ Transfer.new(:migrate, source_email, destination_email, non_logging_options).run
130
153
  end
131
154
 
132
155
  desc(
@@ -140,7 +163,7 @@ module Imap::Backup
140
163
  If a folder list is configured for the SOURCE_EMAIL account,
141
164
  only the folders indicated by the setting are copied.
142
165
 
143
- First, runs the download of the SOURCE_EMAIL account.
166
+ First, it runs the download of the SOURCE_EMAIL account.
144
167
  If the SOURCE_EMAIL account is **not** configured to be in 'mirror' mode,
145
168
  a warning is printed.
146
169
 
@@ -152,6 +175,11 @@ module Imap::Backup
152
175
  config_option
153
176
  quiet_option
154
177
  verbose_option
178
+ method_option(
179
+ "automatic-namespaces",
180
+ type: :boolean,
181
+ desc: "automatically choose delimiters and prefixes"
182
+ )
155
183
  method_option(
156
184
  "destination-delimiter",
157
185
  type: :string,
@@ -176,7 +204,7 @@ module Imap::Backup
176
204
  )
177
205
  def mirror(source_email, destination_email)
178
206
  non_logging_options = Imap::Backup::Logger.setup_logging(options)
179
- Mirror.new(source_email, destination_email, **non_logging_options).run
207
+ Transfer.new(:mirror, source_email, destination_email, non_logging_options).run
180
208
  end
181
209
 
182
210
  desc "remote SUBCOMMAND [OPTIONS]", "View info about online accounts"
@@ -211,9 +239,14 @@ module Imap::Backup
211
239
 
212
240
  desc "stats EMAIL [OPTIONS]", "Print stats for each account folder"
213
241
  long_desc <<~DESC
214
- For each account folder, lists emails that are yet to be downloaded "server",
215
- are downloaded (exist on server and locally) "both" and those which
216
- are only present in the backup (as they have been deleted on the server) "local".
242
+ For each account folder, lists three counts of emails:
243
+
244
+ 1. "server" - those yet to be downloaded,
245
+
246
+ 2. "both" - those that exist on server and are backed up,
247
+
248
+ 3. "local" - those which are only present in the backup (as they have been deleted
249
+ on the server).
217
250
  DESC
218
251
  config_option
219
252
  format_option
@@ -0,0 +1,43 @@
1
+ require "retry_on_error"
2
+
3
+ module Imap; end
4
+
5
+ module Imap::Backup
6
+ module Client; end
7
+
8
+ class Client::AutomaticLoginWrapper
9
+ include RetryOnError
10
+
11
+ LOGIN_RETRY_CLASSES = [::EOFError, ::Errno::ECONNRESET, ::SocketError].freeze
12
+
13
+ attr_reader :client
14
+ attr_reader :login_called
15
+
16
+ def initialize(client:)
17
+ @client = client
18
+ @login_called = false
19
+ end
20
+
21
+ def method_missing(method_name, *arguments, &block)
22
+ if login_called
23
+ client.send(method_name, *arguments, &block)
24
+ else
25
+ do_first_login
26
+ client.send(method_name, *arguments, &block) if method_name != :login
27
+ end
28
+ end
29
+
30
+ def respond_to_missing?(method_name, _include_private = false)
31
+ client.respond_to?(method_name)
32
+ end
33
+
34
+ private
35
+
36
+ def do_first_login
37
+ retry_on_error(errors: LOGIN_RETRY_CLASSES) do
38
+ client.login
39
+ @login_called = true
40
+ end
41
+ end
42
+ end
43
+ end
@@ -29,7 +29,7 @@ module Imap::Backup
29
29
  rollback_on_error do
30
30
  serialized = to_serialized(message)
31
31
  mbox.append serialized
32
- imap.append uid, serialized.length, flags: flags
32
+ imap.append uid, serialized.bytesize, flags: flags
33
33
  rescue StandardError => e
34
34
  raise <<-ERROR.gsub(/^\s*/m, "")
35
35
  [#{folder}] failed to append message #{uid}: #{message}.
@@ -33,7 +33,7 @@ module Imap::Backup
33
33
  tsx.fail_outside_transaction!(:append)
34
34
  mboxrd_message = Email::Mboxrd::Message.new(message)
35
35
  serialized = mboxrd_message.to_serialized
36
- tsx.data[:metadata] << {uid: uid, length: serialized.length, flags: flags}
36
+ tsx.data[:metadata] << {uid: uid, length: serialized.bytesize, flags: flags}
37
37
  mbox.append(serialized)
38
38
  end
39
39
 
@@ -15,6 +15,9 @@ module Imap::Backup
15
15
  end
16
16
 
17
17
  def run
18
+ Logger.logger.debug(
19
+ "[IntegrityChecker] checking '#{imap.pathname}' against '#{mbox.pathname}'"
20
+ )
18
21
  if !imap.valid?
19
22
  message = ".imap file '#{imap.pathname}' is corrupt"
20
23
  raise Serializer::FolderIntegrityError, message
@@ -56,14 +59,18 @@ module Imap::Backup
56
59
  def check_mbox_length!
57
60
  last = imap.messages[-1]
58
61
 
59
- if mbox.length < last.offset + last.length
62
+ expected = last.offset + last.length
63
+ Logger.logger.debug(
64
+ "[IntegrityChecker] mbox length is #{mbox.length}, expected length is #{expected}"
65
+ )
66
+ if mbox.length < expected
60
67
  message =
61
68
  ".mbox file '#{mbox.pathname}' is shorter than indicated by " \
62
69
  ".imap file '#{imap.pathname}'"
63
70
  raise Serializer::FolderIntegrityError, message
64
71
  end
65
72
 
66
- if mbox.length > last.offset + last.length
73
+ if mbox.length > expected
67
74
  message =
68
75
  ".mbox file '#{mbox.pathname}' is longer than indicated by " \
69
76
  ".imap file '#{imap.pathname}'"
@@ -77,6 +84,11 @@ module Imap::Backup
77
84
 
78
85
  next if text.start_with?("From ")
79
86
 
87
+ Logger.logger.debug(
88
+ "[IntegrityChecker] looking for message with UID #{m.uid} " \
89
+ "at offset #{m.offset}, " \
90
+ "mbox starts with '#{text[0..200]}', expecting 'From '"
91
+ )
80
92
  message =
81
93
  "Message #{m.uid} not found at expected offset #{m.offset} " \
82
94
  "in file '#{mbox.pathname}'"
@@ -7,11 +7,9 @@ class Imap::Backup::Setup; end
7
7
  class Imap::Backup::Setup::GlobalOptions
8
8
  class DownloadStrategyChooser
9
9
  attr_reader :config
10
- attr_reader :highline
11
10
 
12
- def initialize(config:, highline:)
11
+ def initialize(config:)
13
12
  @config = config
14
- @highline = highline
15
13
  end
16
14
 
17
15
  def run
@@ -81,5 +79,9 @@ class Imap::Backup::Setup::GlobalOptions
81
79
  highline.ask "Press a key "
82
80
  end
83
81
  end
82
+
83
+ def highline
84
+ Imap::Backup::Setup.highline
85
+ end
84
86
  end
85
87
  end
@@ -8,11 +8,9 @@ module Imap::Backup
8
8
 
9
9
  class Setup::GlobalOptions
10
10
  attr_reader :config
11
- attr_reader :highline
12
11
 
13
- def initialize(config:, highline:)
12
+ def initialize(config:)
14
13
  @config = config
15
- @highline = highline
16
14
  end
17
15
 
18
16
  def run
@@ -46,8 +44,12 @@ module Imap::Backup
46
44
  current = strategies.find { |s| s[:key] == config.download_strategy }
47
45
  changed = config.download_strategy_modified ? " *" : ""
48
46
  menu.choice("change download strategy (currently: '#{current[:description]}')#{changed}") do
49
- DownloadStrategyChooser.new(config: config, highline: Setup.highline).run
47
+ DownloadStrategyChooser.new(config: config).run
50
48
  end
51
49
  end
50
+
51
+ def highline
52
+ Imap::Backup::Setup.highline
53
+ end
52
54
  end
53
55
  end
@@ -76,7 +76,7 @@ module Imap::Backup
76
76
  def modify_global_options(menu)
77
77
  changed = config.modified? ? " *" : ""
78
78
  menu.choice("modify global options#{changed}") do
79
- GlobalOptions.new(config: config, highline: Setup.highline).run
79
+ GlobalOptions.new(config: config).run
80
80
  end
81
81
  end
82
82
 
@@ -1,7 +1,7 @@
1
1
  module Imap; end
2
2
 
3
3
  module Imap::Backup
4
- MAJOR = 11
4
+ MAJOR = 12
5
5
  MINOR = 1
6
6
  REVISION = 0
7
7
  PRE = nil
@@ -1,11 +1,12 @@
1
1
  module RetryOnError
2
- def retry_on_error(errors:, limit: 10)
2
+ def retry_on_error(errors:, limit: 10, on_error: nil)
3
3
  tries ||= 1
4
4
  yield
5
5
  rescue *errors => e
6
6
  if tries < limit
7
7
  message = "#{e}, attempt #{tries} of #{limit}"
8
8
  Imap::Backup::Logger.logger.debug message
9
+ on_error&.call
9
10
  tries += 1
10
11
  retry
11
12
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imap-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 11.1.0
4
+ version: 12.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Yates
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-31 00:00:00.000000000 Z
11
+ date: 2023-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: highline
@@ -247,14 +247,14 @@ files:
247
247
  - lib/imap/backup/cli/helpers.rb
248
248
  - lib/imap/backup/cli/local.rb
249
249
  - lib/imap/backup/cli/local/check.rb
250
- - lib/imap/backup/cli/migrate.rb
251
- - lib/imap/backup/cli/mirror.rb
252
250
  - lib/imap/backup/cli/remote.rb
253
251
  - lib/imap/backup/cli/restore.rb
254
252
  - lib/imap/backup/cli/setup.rb
255
253
  - lib/imap/backup/cli/stats.rb
254
+ - lib/imap/backup/cli/transfer.rb
256
255
  - lib/imap/backup/cli/utils.rb
257
256
  - lib/imap/backup/client/apple_mail.rb
257
+ - lib/imap/backup/client/automatic_login_wrapper.rb
258
258
  - lib/imap/backup/client/default.rb
259
259
  - lib/imap/backup/configuration.rb
260
260
  - lib/imap/backup/downloader.rb
@@ -1,87 +0,0 @@
1
- require "imap/backup/cli/folder_enumerator"
2
- require "imap/backup/migrator"
3
-
4
- module Imap; end
5
-
6
- module Imap::Backup
7
- class CLI::Migrate < Thor
8
- include Thor::Actions
9
- include CLI::Helpers
10
-
11
- attr_reader :destination_delimiter
12
- attr_reader :destination_email
13
- attr_reader :destination_prefix
14
- attr_reader :config_path
15
- attr_reader :reset
16
- attr_reader :source_delimiter
17
- attr_reader :source_email
18
- attr_reader :source_prefix
19
-
20
- def initialize(
21
- source_email,
22
- destination_email,
23
- config: nil,
24
- destination_delimiter: "/",
25
- destination_prefix: "",
26
- reset: false,
27
- source_delimiter: "/",
28
- source_prefix: ""
29
- )
30
- super([])
31
- @destination_delimiter = destination_delimiter
32
- @destination_email = destination_email
33
- @destination_prefix = destination_prefix
34
- @config_path = config
35
- @reset = reset
36
- @source_delimiter = source_delimiter
37
- @source_email = source_email
38
- @source_prefix = source_prefix
39
- end
40
-
41
- no_commands do
42
- def run
43
- check_accounts!
44
- folders.each do |serializer, folder|
45
- Migrator.new(serializer, folder, reset: reset).run
46
- end
47
- end
48
-
49
- def check_accounts!
50
- if destination_email == source_email
51
- raise "Source and destination accounts cannot be the same!"
52
- end
53
-
54
- raise "Account '#{destination_email}' does not exist" if !destination_account
55
-
56
- raise "Account '#{source_email}' does not exist" if !source_account
57
- end
58
-
59
- def config
60
- @config ||= load_config(config: config_path)
61
- end
62
-
63
- def enumerator_options
64
- {
65
- destination: destination_account,
66
- destination_delimiter: destination_delimiter,
67
- destination_prefix: destination_prefix,
68
- source: source_account,
69
- source_delimiter: source_delimiter,
70
- source_prefix: source_prefix
71
- }
72
- end
73
-
74
- def folders
75
- CLI::FolderEnumerator.new(**enumerator_options)
76
- end
77
-
78
- def destination_account
79
- config.accounts.find { |a| a.username == destination_email }
80
- end
81
-
82
- def source_account
83
- config.accounts.find { |a| a.username == source_email }
84
- end
85
- end
86
- end
87
- end
@@ -1,97 +0,0 @@
1
- require "imap/backup/cli/folder_enumerator"
2
- require "imap/backup/mirror"
3
-
4
- module Imap; end
5
-
6
- module Imap::Backup
7
- class CLI::Mirror < Thor
8
- include Thor::Actions
9
- include CLI::Helpers
10
-
11
- attr_reader :destination_delimiter
12
- attr_reader :destination_email
13
- attr_reader :destination_prefix
14
- attr_reader :config_path
15
- attr_reader :source_delimiter
16
- attr_reader :source_email
17
- attr_reader :source_prefix
18
-
19
- def initialize(
20
- source_email,
21
- destination_email,
22
- config: nil,
23
- destination_delimiter: "/",
24
- destination_prefix: "",
25
- source_delimiter: "/",
26
- source_prefix: ""
27
- )
28
- super([])
29
- @destination_delimiter = destination_delimiter
30
- @destination_email = destination_email
31
- @destination_prefix = destination_prefix
32
- @config_path = config
33
- @source_delimiter = source_delimiter
34
- @source_email = source_email
35
- @source_prefix = source_prefix
36
- end
37
-
38
- no_commands do
39
- def run
40
- check_accounts!
41
- warn_if_source_account_is_not_in_mirror_mode
42
-
43
- CLI::Backup.new(config: config_path, accounts: source_email).run
44
-
45
- folders.each do |serializer, folder|
46
- Mirror.new(serializer, folder).run
47
- end
48
- end
49
-
50
- def check_accounts!
51
- if destination_email == source_email
52
- raise "Source and destination accounts cannot be the same!"
53
- end
54
-
55
- raise "Account '#{destination_email}' does not exist" if !destination_account
56
-
57
- raise "Account '#{source_email}' does not exist" if !source_account
58
- end
59
-
60
- def warn_if_source_account_is_not_in_mirror_mode
61
- return if source_account.mirror_mode
62
-
63
- message =
64
- "The account '#{source_account.username}' " \
65
- "is not set up to make mirror backups"
66
- Logger.logger.warn message
67
- end
68
-
69
- def config
70
- @config = load_config(config: config_path)
71
- end
72
-
73
- def enumerator_options
74
- {
75
- destination: destination_account,
76
- destination_delimiter: destination_delimiter,
77
- destination_prefix: destination_prefix,
78
- source: source_account,
79
- source_delimiter: source_delimiter,
80
- source_prefix: source_prefix
81
- }
82
- end
83
-
84
- def folders
85
- CLI::FolderEnumerator.new(**enumerator_options)
86
- end
87
-
88
- def destination_account
89
- config.accounts.find { |a| a.username == destination_email }
90
- end
91
-
92
- def source_account
93
- config.accounts.find { |a| a.username == source_email }
94
- end
95
- end
96
- end
97
- end