imap-backup 11.0.0.rc1 → 11.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: