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 +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:
|