imap-backup 11.0.0.rc1 → 11.0.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: e9cff069fefde8147d1995b254f3f0613575ff4f52232c2fc64c91dc00c49bde
4
- data.tar.gz: a1b8cb3d48f41b2773fd20b842b8d3e3b71e673b6797c83a345e341d087060d8
3
+ metadata.gz: 1392301e243a9affe750a9361f14622228879596b8cb0a29e4944dd91af52d44
4
+ data.tar.gz: d85f59f5387d5f3c5ac1f6ce97a4a2e5a685ca675b02be3982c914774485d5fc
5
5
  SHA512:
6
- metadata.gz: 4e7b83bac1004b853287689e98ed4d03d7c3c87bd05c4a22831ffe592782ff8916ab576778e41de572f872505e3beab5b923ff5d1d78ccf3e1a99d701ff95737
7
- data.tar.gz: a0cc1cc27b225c315a68743dff999fc2d8b0d0c9fd5d68f03641b59d53ddc491da21f318b9c075927263367b9db0a50e80af727de0445c86a049bc55d2a32fdf
6
+ metadata.gz: ef636f637cf6ab48a173439e6d4c908fa7bf0b8a171a7ba60a80b83062e95d30aafba0e52a4647b5c74b23a40b7504132803ad4ddc57907e3b08298e11b9a26c
7
+ data.tar.gz: 1274d7462c5e302bd1ea84529b12d74e6cc0ebe47ba6d755e2f23cdb5829dfde6fdde858c2640abea5f6f13eab8e5867334e82f5e4b7649ae41385a49f5706ca
@@ -1,6 +1,7 @@
1
1
  require "imap/backup/account/folder_ensurer"
2
2
  require "imap/backup/account/local_only_folder_deleter"
3
3
  require "imap/backup/account/serialized_folders"
4
+ require "imap/backup/serializer/delayed_metadata_serializer"
4
5
  require "imap/backup/downloader"
5
6
  require "imap/backup/flag_refresher"
6
7
  require "imap/backup/local_only_message_deleter"
@@ -37,20 +38,38 @@ module Imap::Backup
37
38
  end
38
39
 
39
40
  Logger.logger.debug "[#{folder.name}] running backup"
41
+
40
42
  serializer.apply_uid_validity(folder.uid_validity)
43
+
44
+ download_serializer =
45
+ case account.download_strategy
46
+ when "direct"
47
+ serializer
48
+ when "delay_metadata"
49
+ Serializer::DelayedMetadataSerializer.new(serializer: serializer)
50
+ else
51
+ raise "Unknown download strategy '#{account.download_strategy}'"
52
+ end
53
+
41
54
  downloader = Downloader.new(
42
55
  folder,
43
- serializer,
56
+ download_serializer,
44
57
  multi_fetch_size: account.multi_fetch_size,
45
58
  reset_seen_flags_after_fetch: account.reset_seen_flags_after_fetch
46
59
  )
47
- if account.delay_download_writes
48
- serializer.transaction do
49
- downloader.run
50
- end
51
- else
60
+ # rubocop:disable Lint/RescueException
61
+ download_serializer.transaction do
52
62
  downloader.run
63
+ rescue Exception => e
64
+ message = <<~ERROR
65
+ #{self.class} error #{e}
66
+ #{e.backtrace.join("\n")}
67
+ ERROR
68
+ Logger.logger.error message
69
+ download_serializer.rollback
70
+ raise e
53
71
  end
72
+ # rubocop:enable Lint/RescueException
54
73
  if account.mirror_mode
55
74
  Logger.logger.info "Mirror mode - Deleting messages only present locally"
56
75
  LocalOnlyMessageDeleter.new(folder, serializer).run
@@ -15,7 +15,7 @@ module Imap::Backup
15
15
  attr_reader :mirror_mode
16
16
  attr_reader :server
17
17
  attr_reader :connection_options
18
- attr_accessor :delay_download_writes
18
+ attr_accessor :download_strategy
19
19
  attr_reader :reset_seen_flags_after_fetch
20
20
  attr_reader :changes
21
21
 
@@ -28,7 +28,7 @@ module Imap::Backup
28
28
  @mirror_mode = options[:mirror_mode]
29
29
  @server = options[:server]
30
30
  @connection_options = options[:connection_options]
31
- @delay_download_writes = true
31
+ @download_strategy = options[:download_strategy]
32
32
  @multi_fetch_size_orignal = options[:multi_fetch_size]
33
33
  @reset_seen_flags_after_fetch = options[:reset_seen_flags_after_fetch]
34
34
  @client = nil
@@ -81,7 +81,6 @@ module Imap::Backup
81
81
  h[:mirror_mode] = true if @mirror_mode
82
82
  h[:server] = @server if @server
83
83
  h[:connection_options] = @connection_options if @connection_options
84
- h[:delay_download_writes] = delay_download_writes
85
84
  h[:multi_fetch_size] = multi_fetch_size
86
85
  if @reset_seen_flags_after_fetch
87
86
  h[:reset_seen_flags_after_fetch] = @reset_seen_flags_after_fetch
@@ -1,3 +1,4 @@
1
+ require "net/imap"
1
2
  require "imap/backup/account/backup"
2
3
 
3
4
  module Imap; end
@@ -17,21 +18,34 @@ module Imap::Backup
17
18
  no_commands do
18
19
  def run
19
20
  config = load_config(**options)
21
+ exit_code = nil
20
22
  requested_accounts(config).each do |account|
21
23
  backup = Account::Backup.new(account: account, refresh: refresh)
22
24
  backup.run
23
25
  rescue StandardError => e
24
- message =
25
- "Backup for account '#{account.username}' " \
26
- "failed with error #{e}"
27
- Logger.logger.warn message
26
+ exit_code ||= choose_exit_code(e)
27
+ message = <<~ERROR
28
+ Backup for account '#{account.username}' failed with error #{e}
29
+ #{e.backtrace.join("\n")}
30
+ ERROR
31
+ Logger.logger.error message
28
32
  next
29
33
  end
34
+ exit(exit_code) if exit_code
30
35
  end
31
36
 
32
37
  def refresh
33
38
  options.key?(:refresh) ? !!options[:refresh] : false
34
39
  end
40
+
41
+ def choose_exit_code(exception)
42
+ case exception
43
+ when Net::IMAP::NoResponseError, Errno::ECONNREFUSED
44
+ 111
45
+ else
46
+ 1
47
+ end
48
+ end
35
49
  end
36
50
  end
37
51
  end
@@ -11,11 +11,17 @@ module Imap; end
11
11
  module Imap::Backup
12
12
  class Configuration
13
13
  CONFIGURATION_DIRECTORY = File.expand_path("~/.imap-backup")
14
- VERSION = "2.0".freeze
14
+ VERSION = "2.1".freeze
15
+ DEFAULT_STRATEGY = "delay_metadata".freeze
16
+ DOWNLOAD_STRATEGIES = [
17
+ {key: "direct", description: "write straight to disk"},
18
+ {key: DEFAULT_STRATEGY, description: "delay writing metadata"}
19
+ ].freeze
15
20
 
16
21
  attr_reader :pathname
17
- attr_reader :delay_download_writes
18
- attr_reader :delay_download_writes_modified
22
+ attr_reader :download_strategy
23
+ attr_reader :download_strategy_original
24
+ attr_reader :download_strategy_modified
19
25
 
20
26
  def self.default_pathname
21
27
  File.join(CONFIGURATION_DIRECTORY, "config.json")
@@ -27,8 +33,9 @@ module Imap::Backup
27
33
 
28
34
  def initialize(path: nil)
29
35
  @pathname = path || self.class.default_pathname
30
- @delay_download_writes = false
31
- @delay_download_writes_modified = false
36
+ @download_strategy = nil
37
+ @download_strategy_original = nil
38
+ @download_strategy_modified = false
32
39
  end
33
40
 
34
41
  def path
@@ -44,7 +51,7 @@ module Imap::Backup
44
51
  save_data = {
45
52
  version: VERSION,
46
53
  accounts: accounts.map(&:to_h),
47
- delay_download_writes: delay_download_writes
54
+ download_strategy: download_strategy
48
55
  }
49
56
  File.open(pathname, "w") { |f| f.write(JSON.pretty_generate(save_data)) }
50
57
  FileUtils.chmod(0o600, pathname) if !windows?
@@ -61,18 +68,20 @@ module Imap::Backup
61
68
  end
62
69
  end
63
70
 
64
- def delay_download_writes=(value)
71
+ def download_strategy=(value)
72
+ raise "Unknown strategy '#{value}'" if !DOWNLOAD_STRATEGIES.find { |s| s[:key] == value }
73
+
65
74
  ensure_loaded!
66
75
 
67
- @delay_download_writes = value
68
- @delay_download_writes_modified = true
76
+ @download_strategy = value
77
+ @download_strategy_modified = value != download_strategy_original
69
78
  inject_global_attributes(accounts)
70
79
  end
71
80
 
72
81
  def modified?
73
82
  ensure_loaded!
74
83
 
75
- return true if delay_download_writes_modified
84
+ return true if download_strategy_modified
76
85
 
77
86
  accounts.any? { |a| a.modified? || a.marked_for_deletion? }
78
87
  end
@@ -83,7 +92,8 @@ module Imap::Backup
83
92
  return true if @data
84
93
 
85
94
  data
86
- @delay_download_writes = data[:delay_download_writes]
95
+ @download_strategy = data[:download_strategy]
96
+ @download_strategy_original = data[:download_strategy]
87
97
  true
88
98
  end
89
99
 
@@ -95,14 +105,21 @@ module Imap::Backup
95
105
  )
96
106
  permission_checker.run if !windows?
97
107
  contents = File.read(pathname)
98
- JSON.parse(contents, symbolize_names: true)
108
+ data = JSON.parse(contents, symbolize_names: true)
109
+ data[:download_strategy] =
110
+ if DOWNLOAD_STRATEGIES.find { |s| s[:key] == data[:download_strategy] }
111
+ data[:download_strategy]
112
+ else
113
+ DEFAULT_STRATEGY
114
+ end
115
+ data
99
116
  else
100
117
  {accounts: []}
101
118
  end
102
119
  end
103
120
 
104
121
  def remove_modified_flags
105
- @delay_download_writes_modified = false
122
+ @download_strategy_modified = false
106
123
  accounts.each(&:clear_changes)
107
124
  end
108
125
 
@@ -112,7 +129,7 @@ module Imap::Backup
112
129
 
113
130
  def inject_global_attributes(accounts)
114
131
  accounts.map do |a|
115
- a.delay_download_writes = delay_download_writes
132
+ a.download_strategy = download_strategy
116
133
  a
117
134
  end
118
135
  end
@@ -14,7 +14,7 @@ module Imap::Backup
14
14
  @mbox = mbox
15
15
  end
16
16
 
17
- def single(uid:, message:, flags:)
17
+ def append(uid:, message:, flags:)
18
18
  raise "Can't add messages without uid_validity" if !imap.uid_validity
19
19
 
20
20
  uid = uid.to_i
@@ -27,31 +27,23 @@ module Imap::Backup
27
27
  end
28
28
 
29
29
  rollback_on_error do
30
- do_append uid, message, flags
30
+ serialized = to_serialized(message)
31
+ mbox.append serialized
32
+ imap.append uid, serialized.length, flags: flags
31
33
  rescue StandardError => e
32
34
  raise <<-ERROR.gsub(/^\s*/m, "")
33
- [#{folder}] failed to append message #{uid}:
34
- #{message}. #{e}:
35
+ [#{folder}] failed to append message #{uid}: #{message}.
36
+ #{e}:
35
37
  #{e.backtrace.join("\n")}"
36
38
  ERROR
37
39
  end
38
40
  end
39
41
 
40
- def multi(appends)
41
- rollback_on_error do
42
- appends.each do |a|
43
- do_append a[:uid], a[:message], a[:flags]
44
- end
45
- end
46
- end
47
-
48
42
  private
49
43
 
50
- def do_append(uid, message, flags)
44
+ def to_serialized(message)
51
45
  mboxrd_message = Email::Mboxrd::Message.new(message)
52
- serialized = mboxrd_message.to_serialized
53
- mbox.append serialized
54
- imap.append uid, serialized.length, flags: flags
46
+ mboxrd_message.to_serialized
55
47
  end
56
48
 
57
49
  def rollback_on_error(&block)
@@ -62,6 +54,11 @@ module Imap::Backup
62
54
  Logger.logger.error e
63
55
  imap.rollback
64
56
  mbox.rollback
57
+ rescue SignalException => e
58
+ Logger.logger.error e
59
+ imap.rollback
60
+ mbox.rollback
61
+ raise
65
62
  end
66
63
  end
67
64
  end
@@ -0,0 +1,77 @@
1
+ require "imap/backup/serializer/imap"
2
+ require "imap/backup/serializer/mbox"
3
+ require "imap/backup/serializer/transaction"
4
+
5
+ module Imap; end
6
+
7
+ module Imap::Backup
8
+ class Serializer::DelayedMetadataSerializer
9
+ extend Forwardable
10
+
11
+ attr_reader :serializer
12
+
13
+ def_delegator :serializer, :uids
14
+
15
+ def initialize(serializer:)
16
+ @serializer = serializer
17
+ @tsx = nil
18
+ end
19
+
20
+ def transaction(&block)
21
+ tsx.fail_in_transaction!(:transaction, message: "nested transactions are not supported")
22
+
23
+ # rubocop:disable Lint/RescueException
24
+ tsx.begin({metadata: []}) do
25
+ mbox.transaction do
26
+ block.call
27
+
28
+ commit
29
+ rescue Exception => e
30
+ message = <<~ERROR
31
+ #{self.class} error #{e}
32
+ #{e.backtrace.join("\n")}
33
+ ERROR
34
+ Logger.logger.error message
35
+ mbox.rollback
36
+ raise e
37
+ end
38
+ end
39
+ # rubocop:enable Lint/RescueException
40
+ end
41
+
42
+ def append(uid, message, flags)
43
+ tsx.fail_outside_transaction!(:append)
44
+ mboxrd_message = Email::Mboxrd::Message.new(message)
45
+ serialized = mboxrd_message.to_serialized
46
+ tsx.data[:metadata] << {uid: uid, length: serialized.length, flags: flags}
47
+ mbox.append(serialized)
48
+ end
49
+
50
+ private
51
+
52
+ def commit
53
+ # rubocop:disable Lint/RescueException
54
+ imap.transaction do
55
+ tsx.data[:metadata].each do |m|
56
+ imap.append m[:uid], m[:length], flags: m[:flags]
57
+ end
58
+ rescue Exception => e
59
+ imap.rollback
60
+ raise e
61
+ end
62
+ # rubocop:enable Lint/RescueException
63
+ end
64
+
65
+ def mbox
66
+ @mbox ||= Serializer::Mbox.new(serializer.folder_path)
67
+ end
68
+
69
+ def imap
70
+ @imap ||= Serializer::Imap.new(serializer.folder_path)
71
+ end
72
+
73
+ def tsx
74
+ @tsx ||= Serializer::Transaction.new(owner: self)
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,8 @@
1
1
  require "json"
2
2
 
3
+ require "imap/backup/serializer/message"
4
+ require "imap/backup/serializer/transaction"
5
+
3
6
  module Imap; end
4
7
 
5
8
  module Imap::Backup
@@ -15,26 +18,32 @@ module Imap::Backup
15
18
  @uid_validity = nil
16
19
  @messages = nil
17
20
  @version = nil
18
- @savepoint = nil
21
+ @tsx = nil
19
22
  end
20
23
 
21
24
  def transaction(&block)
22
- fail_in_transaction!(message: "Serializer::Imap: nested transactions are not supported")
25
+ tsx.fail_in_transaction!(:transaction, message: "nested transactions are not supported")
23
26
 
24
27
  ensure_loaded
25
- @savepoint = {messages: messages.dup, uid_validity: uid_validity}
26
-
27
- block.call
28
-
29
- @savepoint = nil
30
- save
28
+ # rubocop:disable Lint/RescueException
29
+ tsx.begin({savepoint: {messages: messages.dup, uid_validity: uid_validity}}) do
30
+ block.call
31
+
32
+ save_internal(version: version, uid_validity: uid_validity, messages: messages) if tsx.data
33
+ rescue Exception => e
34
+ rollback
35
+ raise e
36
+ end
37
+ # rubocop:enable Lint/RescueException
31
38
  end
32
39
 
33
40
  def rollback
34
- fail_outside_transaction!
41
+ tsx.fail_outside_transaction!(:rollback)
42
+
43
+ @messages = tsx.data[:savepoint][:messages]
44
+ @uid_validity = tsx.data[:savepoint][:uid_validity]
35
45
 
36
- @messages = @savepoint[:messages]
37
- @uid_validity = @savepoint[:uid_validity]
46
+ tsx.clear
38
47
  end
39
48
 
40
49
  def pathname
@@ -129,10 +138,16 @@ module Imap::Backup
129
138
  end
130
139
 
131
140
  def save
132
- return if @savepoint
141
+ return if tsx.in_transaction?
133
142
 
134
143
  ensure_loaded
135
144
 
145
+ save_internal(version: version, uid_validity: uid_validity, messages: messages)
146
+ end
147
+
148
+ private
149
+
150
+ def save_internal(version:, uid_validity:, messages:)
136
151
  raise "Cannot save metadata without a uid_validity" if !uid_validity
137
152
 
138
153
  data = {
@@ -144,8 +159,6 @@ module Imap::Backup
144
159
  File.open(pathname, "w") { |f| f.write content }
145
160
  end
146
161
 
147
- private
148
-
149
162
  def ensure_loaded
150
163
  return if loaded
151
164
 
@@ -185,12 +198,8 @@ module Imap::Backup
185
198
  @mbox ||= Serializer::Mbox.new(folder_path)
186
199
  end
187
200
 
188
- def fail_in_transaction!(message: "Serializer::Imap: method not supported inside trasactions")
189
- raise message if @savepoint
190
- end
191
-
192
- def fail_outside_transaction!
193
- raise "This method can only be called inside a transaction" if !@savepoint
201
+ def tsx
202
+ @tsx ||= Serializer::Transaction.new(owner: self)
194
203
  end
195
204
  end
196
205
  end
@@ -0,0 +1,87 @@
1
+ module Imap; end
2
+
3
+ module Imap::Backup
4
+ class Serializer; end
5
+
6
+ class Serializer::FolderIntegrityError < StandardError; end
7
+
8
+ class Serializer::IntegrityChecker
9
+ attr_reader :imap
10
+ attr_reader :mbox
11
+
12
+ def initialize(imap:, mbox:)
13
+ @imap = imap
14
+ @mbox = mbox
15
+ end
16
+
17
+ def run
18
+ if !imap.valid?
19
+ message = ".imap file '#{imap.pathname}' is corrupt"
20
+ raise Serializer::FolderIntegrityError, message
21
+ end
22
+
23
+ if !mbox.exist?
24
+ message = ".mbox file '#{mbox.pathname}' is missing"
25
+ raise Serializer::FolderIntegrityError, message
26
+ end
27
+
28
+ if imap.messages.empty?
29
+ if mbox.length.positive?
30
+ message =
31
+ ".imap file '#{imap.pathname}' lists no messages, " \
32
+ "but .mbox file '#{mbox.pathname}' is not empty"
33
+ raise Serializer::FolderIntegrityError, message
34
+ end
35
+ return
36
+ end
37
+
38
+ check_offset_ordering!
39
+ check_mbox_length!
40
+ check_message_starts!
41
+
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ def check_offset_ordering!
48
+ offsets = imap.messages.map(&:offset)
49
+
50
+ if offsets != offsets.sort
51
+ message = ".imap file '#{imap.pathname}' has offset data which is out of order"
52
+ raise Serializer::FolderIntegrityError, message
53
+ end
54
+ end
55
+
56
+ def check_mbox_length!
57
+ last = imap.messages[-1]
58
+
59
+ if mbox.length < last.offset + last.length
60
+ message =
61
+ ".mbox file '#{mbox.pathname}' is shorter than indicated by " \
62
+ ".imap file '#{imap.pathname}'"
63
+ raise Serializer::FolderIntegrityError, message
64
+ end
65
+
66
+ if mbox.length > last.offset + last.length
67
+ message =
68
+ ".mbox file '#{mbox.pathname}' is longer than indicated by " \
69
+ ".imap file '#{imap.pathname}'"
70
+ raise Serializer::FolderIntegrityError, message
71
+ end
72
+ end
73
+
74
+ def check_message_starts!
75
+ imap.messages.each do |m|
76
+ text = mbox.read(m.offset, m.length)
77
+
78
+ next if text.start_with?("From ")
79
+
80
+ message =
81
+ "Message #{m.uid} not found at expected offset #{m.offset} " \
82
+ "in file '#{mbox.pathname}'"
83
+ raise Serializer::FolderIntegrityError, message
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,3 +1,5 @@
1
+ require "imap/backup/serializer/transaction"
2
+
1
3
  module Imap; end
2
4
 
3
5
  module Imap::Backup
@@ -7,21 +9,32 @@ module Imap::Backup
7
9
 
8
10
  def initialize(folder_path)
9
11
  @folder_path = folder_path
10
- @savepoint = nil
12
+ @tsx = nil
11
13
  end
12
14
 
13
15
  def transaction(&block)
14
- fail_in_transaction!(message: "Nested transactions are not supported")
15
-
16
- @savepoint = {length: length}
17
- block.call
18
- @savepoint = nil
16
+ tsx.fail_in_transaction!(:transaction, message: "nested transactions are not supported")
17
+
18
+ # rubocop:disable Lint/RescueException
19
+ tsx.begin({savepoint: {length: length}}) do
20
+ block.call
21
+ rescue Exception => e
22
+ message = <<~ERROR
23
+ #{self.class} error #{e}
24
+ #{e.backtrace.join("\n")}
25
+ ERROR
26
+ Logger.logger.error message
27
+ rollback
28
+ raise e
29
+ end
30
+ # rubocop:enable Lint/RescueException
19
31
  end
20
32
 
21
33
  def rollback
22
- fail_outside_transaction!
34
+ tsx.fail_outside_transaction!(:rollback)
23
35
 
24
- rewind(@savepoint[:length])
36
+ rewind(tsx.data[:savepoint][:length])
37
+ tsx.clear
25
38
  end
26
39
 
27
40
  def valid?
@@ -83,12 +96,8 @@ module Imap::Backup
83
96
  end
84
97
  end
85
98
 
86
- def fail_in_transaction!(message: "Method not supported inside trasactions")
87
- raise message if savepoint
88
- end
89
-
90
- def fail_outside_transaction!
91
- raise "This method can only be called inside a transaction" if !savepoint
99
+ def tsx
100
+ @tsx ||= Serializer::Transaction.new(owner: self)
92
101
  end
93
102
  end
94
103
  end
@@ -0,0 +1,37 @@
1
+ module Imap; end
2
+
3
+ module Imap::Backup
4
+ class Serializer::Transaction
5
+ attr_reader :owner
6
+ attr_reader :data
7
+
8
+ def initialize(owner:)
9
+ @data = nil
10
+ @owner = owner
11
+ @in_transaction = false
12
+ end
13
+
14
+ def begin(data, &block)
15
+ @data = data
16
+ @in_transaction = true
17
+ block.call
18
+ @in_transaction = false
19
+ end
20
+
21
+ def clear
22
+ @data = nil
23
+ end
24
+
25
+ def in_transaction?
26
+ @in_transaction
27
+ end
28
+
29
+ def fail_in_transaction!(method, message: "not supported inside trasactions")
30
+ raise "#{owner.class}##{method} #{message}" if in_transaction?
31
+ end
32
+
33
+ def fail_outside_transaction!(method)
34
+ raise "#{owner.class}##{method} can only be called inside a transaction" if !in_transaction?
35
+ end
36
+ end
37
+ end
@@ -3,8 +3,8 @@ require "forwardable"
3
3
  require "email/mboxrd/message"
4
4
  require "imap/backup/naming"
5
5
  require "imap/backup/serializer/appender"
6
+ require "imap/backup/serializer/integrity_checker"
6
7
  require "imap/backup/serializer/imap"
7
- require "imap/backup/serializer/message"
8
8
  require "imap/backup/serializer/mbox"
9
9
  require "imap/backup/serializer/message_enumerator"
10
10
  require "imap/backup/serializer/version2_migrator"
@@ -21,43 +21,28 @@ module Imap::Backup
21
21
 
22
22
  extend Forwardable
23
23
 
24
- class FolderIntegrityError < StandardError; end
25
-
26
24
  def_delegator :mbox, :pathname, :mbox_pathname
27
25
  def_delegators :imap, :get, :messages, :uid_validity, :uids, :update_uid
28
26
 
29
27
  attr_reader :folder
30
28
  attr_reader :path
31
- attr_reader :dirty
32
29
 
33
30
  def initialize(path, folder)
34
31
  @path = path
35
32
  @folder = folder
36
33
  @validated = nil
37
- @dirty = nil
38
34
  end
39
35
 
40
36
  def transaction(&block)
41
- fail_in_transaction!(:transaction, message: "nested transactions are not supported")
42
-
43
- validate!
44
- @dirty = {append: []}
45
-
46
37
  block.call
38
+ end
47
39
 
48
- if dirty[:append].any?
49
- appender = Serializer::Appender.new(folder: sanitized, imap: imap, mbox: mbox)
50
- appender.multi(dirty[:append])
51
- end
52
-
53
- @dirty = nil
40
+ def rollback
54
41
  end
55
42
 
56
43
  # Returns true if there are existing, valid files
57
44
  # false otherwise (in which case any existing files are deleted)
58
45
  def validate!
59
- fail_in_transaction!(:validate!)
60
-
61
46
  return true if @validated
62
47
 
63
48
  optionally_migrate2to3
@@ -73,61 +58,10 @@ module Imap::Backup
73
58
  end
74
59
 
75
60
  def check_integrity!
76
- fail_in_transaction!(:check_integrity!)
77
-
78
- if !imap.valid?
79
- message = ".imap file '#{imap.pathname}' is corrupt"
80
- raise FolderIntegrityError, message
81
- end
82
-
83
- if !mbox.exist?
84
- message = ".mbox file '#{mbox.pathname}' is missing"
85
- raise FolderIntegrityError, message
86
- end
87
-
88
- return if imap.messages.empty?
89
-
90
- offsets = imap.messages.map(&:offset)
91
-
92
- if offsets != offsets.sort
93
- message = ".imap file '#{imap.pathname}' has offset data which is out of order"
94
- raise FolderIntegrityError, message
95
- end
96
-
97
- if mbox.length < offsets[-1]
98
- message =
99
- ".imap file '#{imap.pathname}' has offsets past the end " \
100
- "of .mbox file '#{mbox.pathname}'"
101
- raise FolderIntegrityError, message
102
- end
103
-
104
- imap.messages.each do |m|
105
- text = mbox.read(m.offset, m.length)
106
- if text.length < m.length
107
- message = "Message #{m.uid} is incomplete in file '#{mbox.pathname}'"
108
- raise FolderIntegrityError, message
109
- end
110
-
111
- next if text.start_with?("From ")
112
-
113
- message =
114
- "Message #{m.uid} not found at expected offset #{m.offset} " \
115
- "in file '#{mbox.pathname}'"
116
- raise FolderIntegrityError, message
117
- end
118
-
119
- last = imap.messages.last
120
- expected_length = last.offset + last.length
121
- actual_length = mbox.length
122
- return if actual_length == expected_length
123
-
124
- message = "Mbox file '#{mbox.pathname}' contains unexpected trailing data"
125
- raise FolderIntegrityError, message
61
+ IntegrityChecker.new(imap: imap, mbox: mbox).run
126
62
  end
127
63
 
128
64
  def delete
129
- fail_in_transaction!(:delete)
130
-
131
65
  imap.delete
132
66
  @imap = nil
133
67
  mbox.delete
@@ -135,7 +69,6 @@ module Imap::Backup
135
69
  end
136
70
 
137
71
  def apply_uid_validity(value)
138
- fail_in_transaction!(:apply_uid_validity)
139
72
  validate!
140
73
 
141
74
  case
@@ -151,26 +84,19 @@ module Imap::Backup
151
84
  end
152
85
 
153
86
  def force_uid_validity(value)
154
- fail_in_transaction!(:force_uid_validity)
155
87
  validate!
156
88
 
157
89
  internal_force_uid_validity(value)
158
90
  end
159
91
 
160
92
  def append(uid, message, flags)
161
- if dirty
162
- dirty[:append] << {uid: uid, message: message, flags: flags}
163
- else
164
- validate!
93
+ validate!
165
94
 
166
- appender = Serializer::Appender.new(folder: sanitized, imap: imap, mbox: mbox)
167
- appender.single(uid: uid, message: message, flags: flags)
168
- end
95
+ appender = Serializer::Appender.new(folder: sanitized, imap: imap, mbox: mbox)
96
+ appender.append(uid: uid, message: message, flags: flags)
169
97
  end
170
98
 
171
99
  def update(uid, flags: nil)
172
- fail_in_transaction!(:update)
173
-
174
100
  message = imap.get(uid)
175
101
  return if !message
176
102
 
@@ -179,8 +105,6 @@ module Imap::Backup
179
105
  end
180
106
 
181
107
  def each_message(required_uids = nil, &block)
182
- fail_in_transaction!(:each_message)
183
-
184
108
  return enum_for(:each_message, required_uids) if !block
185
109
 
186
110
  required_uids ||= uids
@@ -192,8 +116,6 @@ module Imap::Backup
192
116
  end
193
117
 
194
118
  def filter(&block)
195
- fail_in_transaction!(:filter)
196
-
197
119
  temp_name = Serializer::UnusedNameFinder.new(serializer: self).run
198
120
  temp_folder_path = self.class.folder_path_for(path: path, folder: temp_name)
199
121
  new_mbox = Serializer::Mbox.new(temp_folder_path)
@@ -203,7 +125,7 @@ module Imap::Backup
203
125
  enumerator = Serializer::MessageEnumerator.new(imap: imap)
204
126
  enumerator.run(uids: uids) do |message|
205
127
  keep = block.call(message)
206
- appender.single(uid: message.uid, message: message.body, flags: message.flags) if keep
128
+ appender.append(uid: message.uid, message: message.body, flags: message.flags) if keep
207
129
  end
208
130
  imap.delete
209
131
  new_imap.rename imap.folder_path
@@ -217,6 +139,10 @@ module Imap::Backup
217
139
  self.class.folder_path_for(path: path, folder: sanitized)
218
140
  end
219
141
 
142
+ def sanitized
143
+ @sanitized ||= Naming.to_local_path(folder)
144
+ end
145
+
220
146
  private
221
147
 
222
148
  def rename(new_name)
@@ -249,10 +175,6 @@ module Imap::Backup
249
175
  end
250
176
  end
251
177
 
252
- def sanitized
253
- @sanitized ||= Naming.to_local_path(folder)
254
- end
255
-
256
178
  def optionally_migrate2to3
257
179
  migrator = Version2Migrator.new(folder_path)
258
180
  return if !migrator.required?
@@ -287,13 +209,5 @@ module Imap::Backup
287
209
  rename new_name
288
210
  new_name
289
211
  end
290
-
291
- def fail_in_transaction!(method, message: "not supported inside trasactions")
292
- raise "Serializer##{method} #{message}" if dirty
293
- end
294
-
295
- def fail_outside_transaction!(method)
296
- raise "Serializer##{method} can only be called inside a transaction" if !dirty
297
- end
298
212
  end
299
213
  end
@@ -0,0 +1,85 @@
1
+ require "imap/backup/configuration"
2
+
3
+ module Imap; end
4
+ module Imap::Backup; end
5
+ class Imap::Backup::Setup; end
6
+
7
+ class Imap::Backup::Setup::GlobalOptions
8
+ class DownloadStrategyChooser
9
+ attr_reader :config
10
+ attr_reader :highline
11
+
12
+ def initialize(config:, highline:)
13
+ @config = config
14
+ @highline = highline
15
+ end
16
+
17
+ def run
18
+ catch :done do
19
+ loop do
20
+ Kernel.system("clear")
21
+ create_menu
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def create_menu
29
+ strategies = Imap::Backup::Configuration::DOWNLOAD_STRATEGIES
30
+ highline.choose do |menu|
31
+ menu.header = "Choose a Download Strategy"
32
+
33
+ strategies.each do |s|
34
+ current = s[:key] == config.download_strategy ? " <- current" : ""
35
+ topic = "#{s[:description]}#{current}"
36
+ menu.choice(topic) do
37
+ config.download_strategy = s[:key]
38
+ end
39
+ end
40
+ show_help menu
41
+ menu.choice("(q) return to main menu") { throw :done }
42
+ menu.hidden("quit") { throw :done }
43
+ end
44
+ end
45
+
46
+ def show_help(menu)
47
+ menu.choice("help") do
48
+ Kernel.puts <<~HELP
49
+ This setting changes how often data is written to disk during backups.
50
+
51
+ imap-backup uses two files per folder, a .mbox file with the actual
52
+ messages and a .imap file with metadata like message lengths and their
53
+ offsets within the .mbox file.
54
+
55
+ # write straight to disk
56
+
57
+ With this setting, each message and its metadata are written to disk
58
+ as they are downloaded.
59
+
60
+ This choice uses least memory and so is suitable for backing up onto
61
+ devices with limited memory, like Raspberry Pis.
62
+
63
+ # delay writing metadata
64
+
65
+ This is the default setting.
66
+
67
+ Here, messages (which are potentially very large) are appended to the
68
+ .mbox file as they are received, but the metadata is only written to
69
+ the .imap file once all the folder's messages have been downloaded.
70
+
71
+ This choice uses a little more memory than the previous setting, but
72
+ is **much** faster for large folders (potentially >30 times for
73
+ folders with >100k messages) and is less wearing on the disk.
74
+
75
+ # Other Performance Settings
76
+
77
+ Another configuration which affects backup performance is the
78
+ `multi_fetch_size` account-level setting.
79
+
80
+ HELP
81
+ highline.ask "Press a key "
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,53 @@
1
+ require "imap/backup/configuration"
2
+ require "imap/backup/setup/global_options/download_strategy_chooser"
3
+
4
+ module Imap; end
5
+
6
+ module Imap::Backup
7
+ class Setup; end
8
+
9
+ class Setup::GlobalOptions
10
+ attr_reader :config
11
+ attr_reader :highline
12
+
13
+ def initialize(config:, highline:)
14
+ @config = config
15
+ @highline = highline
16
+ end
17
+
18
+ def run
19
+ catch :done do
20
+ loop do
21
+ Kernel.system("clear")
22
+ show_menu
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def show_menu
30
+ highline.choose do |menu|
31
+ menu.header = <<~MENU.chomp
32
+ Global Options
33
+
34
+ These settings affect all accounts.
35
+
36
+ Choose an action
37
+ MENU
38
+ change_download_strategy menu
39
+ menu.choice("(q) return to main menu") { throw :done }
40
+ menu.hidden("quit") { throw :done }
41
+ end
42
+ end
43
+
44
+ def change_download_strategy(menu)
45
+ strategies = Imap::Backup::Configuration::DOWNLOAD_STRATEGIES
46
+ current = strategies.find { |s| s[:key] == config.download_strategy }
47
+ changed = config.download_strategy_modified ? " *" : ""
48
+ menu.choice("change download strategy (currently: '#{current[:description]}')#{changed}") do
49
+ DownloadStrategyChooser.new(config: config, highline: Setup.highline).run
50
+ end
51
+ end
52
+ end
53
+ end
@@ -2,6 +2,7 @@ require "highline"
2
2
 
3
3
  require "email/provider"
4
4
  require "imap/backup/account"
5
+ require "imap/backup/setup/global_options"
5
6
  require "imap/backup/setup/helpers"
6
7
 
7
8
  module Imap; end
@@ -39,7 +40,7 @@ module Imap::Backup
39
40
  MENU
40
41
  account_items menu
41
42
  add_account_item menu
42
- toggle_delay_download_writes menu
43
+ modify_global_options menu
43
44
  if config.modified?
44
45
  menu.choice("save and exit") do
45
46
  config.save
@@ -47,7 +48,8 @@ module Imap::Backup
47
48
  end
48
49
  menu.choice("exit without saving changes") { throw :done }
49
50
  else
50
- menu.choice("quit") { throw :done }
51
+ menu.choice("(q) quit") { throw :done }
52
+ menu.hidden("quit") { throw :done }
51
53
  end
52
54
  end
53
55
  end
@@ -71,13 +73,10 @@ module Imap::Backup
71
73
  end
72
74
  end
73
75
 
74
- def toggle_delay_download_writes(menu)
75
- new_value = config.delay_download_writes ? false : true
76
- modified = config.delay_download_writes_modified ? " *" : ""
77
- change = config.delay_download_writes ? "don't delay" : "delay"
78
- menu_item = "#{change} download writes#{modified}"
79
- menu.choice(menu_item) do
80
- config.delay_download_writes = new_value
76
+ def modify_global_options(menu)
77
+ changed = config.modified? ? " *" : ""
78
+ menu.choice("modify global options#{changed}") do
79
+ GlobalOptions.new(config: config, highline: Setup.highline).run
81
80
  end
82
81
  end
83
82
 
@@ -4,6 +4,6 @@ module Imap::Backup
4
4
  MAJOR = 11
5
5
  MINOR = 0
6
6
  REVISION = 0
7
- PRE = "rc1".freeze
7
+ PRE = nil
8
8
  VERSION = [MAJOR, MINOR, REVISION, PRE].compact.map(&:to_s).join(".")
9
9
  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.0.0.rc1
4
+ version: 11.0.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-07-18 00:00:00.000000000 Z
11
+ date: 2023-07-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: highline
@@ -266,13 +266,16 @@ files:
266
266
  - lib/imap/backup/naming.rb
267
267
  - lib/imap/backup/serializer.rb
268
268
  - lib/imap/backup/serializer/appender.rb
269
+ - lib/imap/backup/serializer/delayed_metadata_serializer.rb
269
270
  - lib/imap/backup/serializer/directory.rb
270
271
  - lib/imap/backup/serializer/folder_maker.rb
271
272
  - lib/imap/backup/serializer/imap.rb
273
+ - lib/imap/backup/serializer/integrity_checker.rb
272
274
  - lib/imap/backup/serializer/mbox.rb
273
275
  - lib/imap/backup/serializer/message.rb
274
276
  - lib/imap/backup/serializer/message_enumerator.rb
275
277
  - lib/imap/backup/serializer/permission_checker.rb
278
+ - lib/imap/backup/serializer/transaction.rb
276
279
  - lib/imap/backup/serializer/unused_name_finder.rb
277
280
  - lib/imap/backup/serializer/version2_migrator.rb
278
281
  - lib/imap/backup/setup.rb
@@ -283,6 +286,8 @@ files:
283
286
  - lib/imap/backup/setup/connection_tester.rb
284
287
  - lib/imap/backup/setup/email.rb
285
288
  - lib/imap/backup/setup/folder_chooser.rb
289
+ - lib/imap/backup/setup/global_options.rb
290
+ - lib/imap/backup/setup/global_options/download_strategy_chooser.rb
286
291
  - lib/imap/backup/setup/helpers.rb
287
292
  - lib/imap/backup/thunderbird/mailbox_exporter.rb
288
293
  - lib/imap/backup/uploader.rb
@@ -305,9 +310,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
305
310
  version: '2.6'
306
311
  required_rubygems_version: !ruby/object:Gem::Requirement
307
312
  requirements:
308
- - - ">"
313
+ - - ">="
309
314
  - !ruby/object:Gem::Version
310
- version: 1.3.1
315
+ version: '0'
311
316
  requirements: []
312
317
  rubygems_version: 3.3.7
313
318
  signing_key: