imap-backup 16.0.0 → 16.2.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: ac22de1cd99aab34b9d478d2ad8727faf67bbac635cf6ef80335d27012328a29
4
- data.tar.gz: 7b74833c6e1752c1a25a201d3e3f40a05f8b966bebe38796843ee4f133191aff
3
+ metadata.gz: 773e831319b208a930da663eae601d32236281104f4712960bfdb42dbc9d7794
4
+ data.tar.gz: 0da5b5f25c8e89022b387f08e5a5bee101284067fa6c5b9db7b35c95890e92da
5
5
  SHA512:
6
- metadata.gz: b0147bfef559ef12c9f6b3e41eced66cade0de85fa418bafb81eed6cfa06bd6e751d818cb8d058768a8259e69526c2a90aa504389a188146722f66f2d2671b2c
7
- data.tar.gz: 6b48724665aa1383094053164de511e2bcfd1b3bf2b32d28bf659740e96e1e5f32a9dc37af32ec6cb0fd23fb06bd863d567ca78135bac27bb2662d6b6b6f5118
6
+ metadata.gz: ccd49d19ec804c70c5aaaadcc40129ad8fcccf129c8de01c2c9e74cec0068bd2268fe6ea4e79f1d313539069e727891da9d0fc16e91a9e64c2301b00248204d8
7
+ data.tar.gz: 92553f44cf28d9c3a19b4cc3ff63fbd5a2dae59056663e9222ba0481ac6768d77f91c33e6d2aec652e451fbc1a5c9ae8f3933fcc2b9d601a5d3ce04865720af3
@@ -1,9 +1,7 @@
1
1
  require "socket"
2
2
 
3
- require "imap/backup/client/apple_mail"
4
3
  require "imap/backup/client/automatic_login_wrapper"
5
4
  require "imap/backup/client/default"
6
- require "imap/backup/email/provider"
7
5
 
8
6
  module Imap; end
9
7
 
@@ -14,39 +12,17 @@ module Imap::Backup
14
12
  class Account::ClientFactory
15
13
  def initialize(account:)
16
14
  @account = account
17
- @provider = nil
18
- @server = nil
19
15
  end
20
16
 
21
17
  # @return [Client::AutomaticLoginWrapper] a client for the account
22
18
  def run
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
19
+ Logger.logger.debug("Creating IMAP instance")
20
+ client = Client::Default.new(account)
33
21
  Client::AutomaticLoginWrapper.new(client: client)
34
22
  end
35
23
 
36
24
  private
37
25
 
38
26
  attr_reader :account
39
-
40
- def provider
41
- @provider ||= Email::Provider.for_address(account.username)
42
- end
43
-
44
- def provider_options
45
- provider.options.merge(account.connection_options || {})
46
- end
47
-
48
- def server
49
- @server ||= account.server || provider.host
50
- end
51
27
  end
52
28
  end
@@ -38,7 +38,6 @@ module Imap::Backup
38
38
  Logger.logger.debug "Folder '#{name}' exists"
39
39
  true
40
40
  rescue FolderNotFound
41
- Logger.logger.debug "Folder '#{name}' does not exist"
42
41
  false
43
42
  end
44
43
 
@@ -189,9 +188,9 @@ module Imap::Backup
189
188
  def examine
190
189
  client.examine(utf7_encoded_name)
191
190
  rescue Net::IMAP::NoResponseError
192
- Imap::Backup::Logger.logger.warn "Folder '#{name}' does not exist on server"
193
- Imap::Backup::Logger.logger.warn caller.join("\n")
194
- raise FolderNotFound, "Folder '#{name}' does not exist on server"
191
+ message = "Folder '#{name}' does not exist on server"
192
+ Imap::Backup::Logger.logger.warn message
193
+ raise FolderNotFound, message
195
194
  end
196
195
 
197
196
  def extract_uid(response)
@@ -44,7 +44,7 @@ module Imap::Backup
44
44
  # The name of the download strategy to adopt during backups
45
45
  # @return [String]
46
46
  attr_accessor :download_strategy
47
- # Should 'Seen' flags be cached before fetchiong emails and
47
+ # Should 'Seen' flags be cached before fetching emails and
48
48
  # rewritten to the server afterwards?
49
49
  #
50
50
  # Some IMAP providers, notably Apple Mail, set the '\Seen' flag
@@ -57,6 +57,12 @@ module Imap::Backup
57
57
  # mark messages as '\Seen' when accessed).
58
58
  # @return [Boolean]
59
59
  attr_reader :reset_seen_flags_after_fetch
60
+ # The status of the account - controls backup and migration behavior
61
+ # "active" - the account is available for backup and migration,
62
+ # "archived" - the account is available for migration, but not backup,
63
+ # "offline" - the account is not available for backup or migration.
64
+ # @return [String] one of "active" (the default), "archived", or "offline"
65
+ attr_reader :status
60
66
 
61
67
  def initialize(options)
62
68
  check_options!(options)
@@ -72,6 +78,7 @@ module Imap::Backup
72
78
  @download_strategy = options[:download_strategy]
73
79
  @multi_fetch_size_orignal = options[:multi_fetch_size]
74
80
  @reset_seen_flags_after_fetch = options[:reset_seen_flags_after_fetch]
81
+ @status = options[:status] || DEFAULT_STATUS
75
82
  @client = nil
76
83
  @changes = {}
77
84
  @marked_for_deletion = false
@@ -122,7 +129,11 @@ module Imap::Backup
122
129
 
123
130
  # @return [Hash] all Account data for serialization
124
131
  def to_h
125
- h = {username: @username, password: @password}
132
+ h = {
133
+ username: @username,
134
+ password: @password,
135
+ status: status
136
+ }
126
137
  h[:local_path] = @local_path if @local_path
127
138
  h[:folders] = @folders if @folders
128
139
  h[:folder_blacklist] = true if @folder_blacklist
@@ -211,11 +222,7 @@ module Imap::Backup
211
222
  def multi_fetch_size
212
223
  @multi_fetch_size ||= begin
213
224
  int = @multi_fetch_size_orignal.to_i
214
- if int.positive?
215
- int
216
- else
217
- DEFAULT_MULTI_FETCH_SIZE
218
- end
225
+ int.positive? ? int : DEFAULT_MULTI_FETCH_SIZE
219
226
  end
220
227
  end
221
228
 
@@ -234,6 +241,44 @@ module Imap::Backup
234
241
  update(:reset_seen_flags_after_fetch, value)
235
242
  end
236
243
 
244
+ # Sets the status attribute and marks it as modified, storing the original value
245
+ #
246
+ # @param value [String] one of "active", "archived", or "offline"
247
+ # @raise [ArgumentError] if the value is not a valid status
248
+ # @return [void]
249
+ def status=(value)
250
+ if !VALID_STATUSES.include?(value)
251
+ raise ArgumentError, "status must be one of: #{VALID_STATUSES.join(', ')}"
252
+ end
253
+
254
+ update(:status, value)
255
+ end
256
+
257
+ # @return [Boolean] true if the account is active
258
+ def active?
259
+ @status == "active"
260
+ end
261
+
262
+ # @return [Boolean] true if the account is archived
263
+ def archived?
264
+ @status == "archived"
265
+ end
266
+
267
+ # @return [Boolean] true if the account is offline
268
+ def offline?
269
+ @status == "offline"
270
+ end
271
+
272
+ # @return [Boolean] true if the account is available for backup operations
273
+ def available_for_backup?
274
+ active?
275
+ end
276
+
277
+ # @return [Boolean] true if the account is available for migration operations
278
+ def available_for_migration?
279
+ active? || archived?
280
+ end
281
+
237
282
  private
238
283
 
239
284
  attr_reader :changes
@@ -241,9 +286,11 @@ module Imap::Backup
241
286
  REQUIRED_ATTRIBUTES = %i[password username].freeze
242
287
  OPTIONAL_ATTRIBUTES = %i[
243
288
  connection_options download_strategy folders folder_blacklist local_path mirror_mode
244
- multi_fetch_size reset_seen_flags_after_fetch server
289
+ multi_fetch_size reset_seen_flags_after_fetch server status
245
290
  ].freeze
246
291
  KNOWN_ATTRIBUTES = REQUIRED_ATTRIBUTES + OPTIONAL_ATTRIBUTES
292
+ VALID_STATUSES = %w[active archived offline].freeze
293
+ DEFAULT_STATUS = "active".freeze
247
294
 
248
295
  def check_options!(options)
249
296
  missing_required = REQUIRED_ATTRIBUTES - options.keys
@@ -28,6 +28,8 @@ module Imap::Backup
28
28
  config = load_config(**options)
29
29
  exit_code = nil
30
30
  accounts = requested_accounts(config)
31
+ # Filter to only include accounts available for backup
32
+ accounts = accounts.select(&:available_for_backup?)
31
33
  if accounts.none?
32
34
  Logger.logger.warn "No matching accounts found to backup"
33
35
  return
@@ -88,6 +88,16 @@ module Imap::Backup
88
88
  raise "Account '#{destination_email}' does not exist" if !destination_account
89
89
 
90
90
  raise "Account '#{source_email}' does not exist" if !source_account
91
+
92
+ if !source_account.available_for_migration?
93
+ raise "Account '#{source_email}' is not available for migration " \
94
+ "(status: #{source_account.status})"
95
+ end
96
+
97
+ return if destination_account.available_for_migration?
98
+
99
+ raise "Account '#{destination_email}' is not available for migration " \
100
+ "(status: #{destination_account.status})"
91
101
  end
92
102
 
93
103
  def choose_prefixes_and_delimiters!
@@ -1,6 +1,7 @@
1
1
  require "forwardable"
2
2
  require "net/imap"
3
3
 
4
+ require "imap/backup/email/provider"
4
5
  require "imap/backup/logger"
5
6
 
6
7
  module Imap; end
@@ -17,10 +18,8 @@ module Imap::Backup
17
18
  responses uid_fetch uid_search uid_store
18
19
  )
19
20
 
20
- def initialize(server, account, options)
21
+ def initialize(account)
21
22
  @account = account
22
- @options = options
23
- @server = server
24
23
  @state = nil
25
24
  end
26
25
 
@@ -32,7 +31,10 @@ module Imap::Backup
32
31
 
33
32
  return [] if mailbox_lists.nil?
34
33
 
35
- mailbox_lists.map { |ml| extract_name(ml) }
34
+ ignored_tags = provider.folder_ignore_tags
35
+ mailbox_lists.
36
+ select { |ml| ml.attr & ignored_tags == [] }.
37
+ map { |ml| extract_name(ml) }
36
38
  end
37
39
 
38
40
  # Logs in to the account on the IMAP server
@@ -83,8 +85,6 @@ module Imap::Backup
83
85
  private
84
86
 
85
87
  attr_reader :account
86
- attr_reader :options
87
- attr_reader :server
88
88
  attr_accessor :state
89
89
 
90
90
  def imap
@@ -100,17 +100,36 @@ module Imap::Backup
100
100
  account.password.gsub(/./, "x")
101
101
  end
102
102
 
103
+ def provider
104
+ @provider ||= Email::Provider.for_address(account.username)
105
+ end
106
+
107
+ def options
108
+ @options ||= provider.options.merge(account.connection_options || {})
109
+ end
110
+
111
+ def server
112
+ @server ||= account.server || provider.host
113
+ end
114
+
103
115
  # 6.3.8. LIST Command
104
116
  # An empty ("" string) mailbox name argument is a special request to
105
117
  # return the hierarchy delimiter and the root name of the name given
106
118
  # in the reference.
107
119
  def provider_root
108
- @provider_root ||= begin
109
- Logger.logger.debug "Fetching provider root"
110
- root_info = imap.list("", "")[0]
111
- Logger.logger.debug "Provider root is '#{root_info.name}'"
112
- root_info.name
113
- end
120
+ @provider_root ||=
121
+ if provider.root
122
+ Logger.logger.debug "Using fixed provider root '#{provider.root}'"
123
+ provider.root
124
+ else
125
+ Logger.logger.debug "Fetching provider root"
126
+ result = imap.list("", "")
127
+ raise "IMAP server did not return root folder for #{account.username}" if result.empty?
128
+
129
+ root_info = result[0]
130
+ Logger.logger.debug "Provider root is '#{root_info.name}'"
131
+ root_info.name
132
+ end
114
133
  end
115
134
  end
116
135
  end
@@ -13,13 +13,15 @@ module Imap::Backup
13
13
  @serializer = serializer
14
14
  @multi_fetch_size = multi_fetch_size
15
15
  @reset_seen_flags_after_fetch = reset_seen_flags_after_fetch
16
+ @folder_uids = nil
17
+ @serializer_uids = nil
16
18
  @uids = nil
17
19
  end
18
20
 
19
21
  # Runs the downloader
20
22
  # @return [void]
21
23
  def run
22
- debug("#{serializer_uids.count} already messages already downloaded")
24
+ debug("#{serializer_uids.count} messages already downloaded")
23
25
  debug("#{folder_uids.count} messages on server")
24
26
  local_only_count = (serializer_uids - folder_uids).count
25
27
  if local_only_count.positive?
@@ -33,15 +35,7 @@ module Imap::Backup
33
35
 
34
36
  info("#{uids.count} new messages")
35
37
 
36
- uids.each_slice(multi_fetch_size).with_index do |block, i|
37
- multifetch_failed = download_block(block, i)
38
- raise MultiFetchFailedError if multifetch_failed
39
- end
40
- rescue MultiFetchFailedError
41
- @count = nil
42
- @multi_fetch_size = 1
43
- @uids = nil
44
- retry
38
+ download
45
39
  rescue Net::IMAP::ByeResponseError
46
40
  folder.client.reconnect
47
41
  retry
@@ -54,6 +48,21 @@ module Imap::Backup
54
48
  attr_reader :multi_fetch_size
55
49
  attr_reader :reset_seen_flags_after_fetch
56
50
 
51
+ def download
52
+ block_count = (uids.count / multi_fetch_size.to_f).ceil
53
+ uids.each_slice(multi_fetch_size).with_index do |block, i|
54
+ debug("Downloading #{block.count} messages (block #{i + 1}/#{block_count})")
55
+ multifetch_failed = download_block(block, i)
56
+ raise MultiFetchFailedError if multifetch_failed
57
+ end
58
+ rescue MultiFetchFailedError
59
+ @multi_fetch_size = 1
60
+ @uids = nil
61
+ @folder_uids = nil
62
+ @serializer_uids = nil
63
+ retry
64
+ end
65
+
57
66
  def download_block(block, index)
58
67
  uids_and_bodies =
59
68
  if reset_seen_flags_after_fetch
@@ -10,6 +10,11 @@ module Imap::Backup
10
10
  "imap.mail.me.com"
11
11
  end
12
12
 
13
+ # With Apple Mails's IMAP, passing "/" to list results in an empty list
14
+ def root
15
+ ""
16
+ end
17
+
13
18
  def sets_seen_flags_on_fetch?
14
19
  true
15
20
  end
@@ -6,11 +6,22 @@ module Imap::Backup
6
6
 
7
7
  # Supplies defaults for email provider behaviour
8
8
  class Email::Provider::Base
9
+ # @return [Array<Symbol>] tags to ignore when listing folders
10
+ def folder_ignore_tags
11
+ []
12
+ end
13
+
9
14
  # @return [Hash] defaults for the Net::IMAP connection
10
15
  def options
11
16
  {port: 993, ssl: {min_version: OpenSSL::SSL::TLS1_2_VERSION}}
12
17
  end
13
18
 
19
+ # By default, we query the server for this value.
20
+ # It is only fixed for Apple Mail accounts.
21
+ # @return [String, nil] any fixed value to use when requesting the list of account folders
22
+ def root
23
+ end
24
+
14
25
  def sets_seen_flags_on_fetch?
15
26
  false
16
27
  end
@@ -5,6 +5,11 @@ module Imap; end
5
5
  module Imap::Backup
6
6
  # Provides overrides for GMail accounts
7
7
  class Email::Provider::GMail < Email::Provider::Base
8
+ # https://imap-use.u.washington.narkive.com/RYMsOHTN/imap-protocol-status-on-a-noselect-mailbox
9
+ def folder_ignore_tags
10
+ [:Noselect]
11
+ end
12
+
8
13
  # @return [String] the GMail IMAP server host name
9
14
  def host
10
15
  "imap.gmail.com"
@@ -30,7 +30,8 @@ module Imap::Backup
30
30
  path,
31
31
  folders,
32
32
  multi_fetch,
33
- reset_seen_flags_after_fetch
33
+ reset_seen_flags_after_fetch,
34
+ status_row
34
35
  ].compact
35
36
 
36
37
  menu.header = <<~HEADER.chomp
@@ -127,6 +128,12 @@ module Imap::Backup
127
128
  ["changes to unread flags will be reset during download"]
128
129
  end
129
130
 
131
+ def status_row
132
+ return nil if account.status == "active"
133
+
134
+ ["status", account.status]
135
+ end
136
+
130
137
  def format_rows(rows)
131
138
  largest_label, _value = rows.max_by do |(label, value)|
132
139
  if value
@@ -56,6 +56,7 @@ module Imap::Backup
56
56
  choose_folders menu
57
57
  modify_multi_fetch_size menu
58
58
  toggle_reset_seen_flags_after_fetch menu
59
+ rotate_status menu
59
60
  delete_account menu
60
61
  menu.choice("(q) return to main menu") { throw :done }
61
62
  menu.hidden("quit") { throw :done }
@@ -151,6 +152,19 @@ module Imap::Backup
151
152
  end
152
153
  end
153
154
 
155
+ def rotate_status(menu)
156
+ current_status = account.status
157
+ statuses = %w[active archived offline]
158
+ current_index = statuses.index(current_status) || 0
159
+ next_index = (current_index + 1) % statuses.length
160
+ next_status = statuses[next_index]
161
+
162
+ menu_item = "change status (currently: #{current_status} -> #{next_status})"
163
+ menu.choice(menu_item) do
164
+ account.status = next_status
165
+ end
166
+ end
167
+
154
168
  def test_connection(menu)
155
169
  menu.choice("test connection") do
156
170
  result = Setup::ConnectionTester.new(account).test
@@ -4,7 +4,7 @@ module Imap::Backup
4
4
  # @private
5
5
  MAJOR = 16
6
6
  # @private
7
- MINOR = 0
7
+ MINOR = 2
8
8
  # @private
9
9
  REVISION = 0
10
10
  # @private
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: 16.0.0
4
+ version: 16.2.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: 2025-06-26 00:00:00.000000000 Z
11
+ date: 2025-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: highline
@@ -192,7 +192,6 @@ files:
192
192
  - lib/imap/backup/cli/stats.rb
193
193
  - lib/imap/backup/cli/transfer.rb
194
194
  - lib/imap/backup/cli/utils.rb
195
- - lib/imap/backup/client/apple_mail.rb
196
195
  - lib/imap/backup/client/automatic_login_wrapper.rb
197
196
  - lib/imap/backup/client/default.rb
198
197
  - lib/imap/backup/configuration.rb
@@ -1,15 +0,0 @@
1
- require "imap/backup/client/default"
2
-
3
- module Imap; end
4
-
5
- module Imap::Backup
6
- # Overrides default IMAP client behaviour for Apple Mail accounts
7
- class Client::AppleMail < Client::Default
8
- # With Apple Mails's IMAP, passing "/" to list
9
- # results in an empty list
10
- # @return [String] the value to use when requesting the list of account folders
11
- def provider_root
12
- ""
13
- end
14
- end
15
- end