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 +4 -4
- data/lib/imap/backup/account/backup.rb +25 -6
- data/lib/imap/backup/account.rb +2 -3
- data/lib/imap/backup/cli/backup.rb +18 -4
- data/lib/imap/backup/configuration.rb +31 -14
- data/lib/imap/backup/serializer/appender.rb +13 -16
- data/lib/imap/backup/serializer/delayed_metadata_serializer.rb +77 -0
- data/lib/imap/backup/serializer/imap.rb +29 -20
- data/lib/imap/backup/serializer/integrity_checker.rb +87 -0
- data/lib/imap/backup/serializer/mbox.rb +23 -14
- data/lib/imap/backup/serializer/transaction.rb +37 -0
- data/lib/imap/backup/serializer.rb +12 -98
- data/lib/imap/backup/setup/global_options/download_strategy_chooser.rb +85 -0
- data/lib/imap/backup/setup/global_options.rb +53 -0
- data/lib/imap/backup/setup.rb +8 -9
- data/lib/imap/backup/version.rb +1 -1
- metadata +9 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1392301e243a9affe750a9361f14622228879596b8cb0a29e4944dd91af52d44
|
4
|
+
data.tar.gz: d85f59f5387d5f3c5ac1f6ce97a4a2e5a685ca675b02be3982c914774485d5fc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
|
48
|
-
|
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
|
data/lib/imap/backup/account.rb
CHANGED
@@ -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 :
|
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
|
-
@
|
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
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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.
|
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 :
|
18
|
-
attr_reader :
|
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
|
-
@
|
31
|
-
@
|
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
|
-
|
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
|
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
|
-
@
|
68
|
-
@
|
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
|
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
|
-
@
|
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
|
-
@
|
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.
|
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
|
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
|
-
|
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
|
-
#{
|
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
|
44
|
+
def to_serialized(message)
|
51
45
|
mboxrd_message = Email::Mboxrd::Message.new(message)
|
52
|
-
|
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
|
-
@
|
21
|
+
@tsx = nil
|
19
22
|
end
|
20
23
|
|
21
24
|
def transaction(&block)
|
22
|
-
fail_in_transaction!(message: "
|
25
|
+
tsx.fail_in_transaction!(:transaction, message: "nested transactions are not supported")
|
23
26
|
|
24
27
|
ensure_loaded
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
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
|
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
|
189
|
-
|
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
|
-
@
|
12
|
+
@tsx = nil
|
11
13
|
end
|
12
14
|
|
13
15
|
def transaction(&block)
|
14
|
-
fail_in_transaction!(message: "
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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(
|
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
|
87
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
162
|
-
dirty[:append] << {uid: uid, message: message, flags: flags}
|
163
|
-
else
|
164
|
-
validate!
|
93
|
+
validate!
|
165
94
|
|
166
|
-
|
167
|
-
|
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.
|
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
|
data/lib/imap/backup/setup.rb
CHANGED
@@ -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
|
-
|
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
|
75
|
-
|
76
|
-
|
77
|
-
|
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
|
|
data/lib/imap/backup/version.rb
CHANGED
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
|
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-
|
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:
|
315
|
+
version: '0'
|
311
316
|
requirements: []
|
312
317
|
rubygems_version: 3.3.7
|
313
318
|
signing_key:
|