imap-backup 1.4.2 → 2.0.0.rc1
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/.rspec-all +2 -0
- data/README.md +36 -3
- data/bin/imap-backup +5 -0
- data/imap-backup.gemspec +7 -0
- data/lib/email/mboxrd/message.rb +13 -10
- data/lib/email/provider.rb +11 -2
- data/lib/imap/backup.rb +2 -1
- data/lib/imap/backup/account/connection.rb +41 -0
- data/lib/imap/backup/account/folder.rb +42 -2
- data/lib/imap/backup/serializer.rb +6 -0
- data/lib/imap/backup/serializer/mbox.rb +63 -83
- data/lib/imap/backup/serializer/mbox_store.rb +214 -0
- data/lib/imap/backup/uploader.rb +26 -0
- data/lib/imap/backup/utils.rb +3 -3
- data/lib/imap/backup/version.rb +5 -4
- data/spec/features/backup_spec.rb +85 -7
- data/spec/features/restore_spec.rb +112 -0
- data/spec/features/support/backup_directory.rb +21 -1
- data/spec/features/support/email_server.rb +66 -3
- data/spec/features/support/shared/message_fixtures.rb +2 -0
- data/spec/fixtures/connection.yml +2 -3
- data/spec/unit/account/connection_spec.rb +19 -5
- data/spec/unit/configuration/account_spec.rb +2 -1
- data/spec/unit/email/provider_spec.rb +1 -5
- data/spec/unit/serializer/mbox_spec.rb +52 -89
- data/spec/unit/serializer/mbox_store_spec.rb +117 -0
- data/spec/unit/utils_spec.rb +3 -3
- metadata +17 -8
- data/lib/imap/backup/serializer/base.rb +0 -16
- data/spec/unit/serializer/base_spec.rb +0 -19
@@ -3,7 +3,7 @@ module BackupDirectoryHelpers
|
|
3
3
|
from = fixture("connection")[:username]
|
4
4
|
subject = options[:subject]
|
5
5
|
body = options[:body]
|
6
|
-
body_and_headers = <<-EOT
|
6
|
+
body_and_headers = <<-EOT
|
7
7
|
From: #{from}
|
8
8
|
Subject: #{subject}
|
9
9
|
|
@@ -13,6 +13,14 @@ Subject: #{subject}
|
|
13
13
|
"From #{from}\n#{body_and_headers}\n"
|
14
14
|
end
|
15
15
|
|
16
|
+
def imap_data(uid_validity, uids)
|
17
|
+
{
|
18
|
+
version: 2,
|
19
|
+
uid_validity: uid_validity,
|
20
|
+
uids: uids
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
16
24
|
def mbox_content(name)
|
17
25
|
File.read(mbox_path(name))
|
18
26
|
end
|
@@ -20,6 +28,18 @@ Subject: #{subject}
|
|
20
28
|
def mbox_path(name)
|
21
29
|
File.join(local_backup_path, name + ".mbox")
|
22
30
|
end
|
31
|
+
|
32
|
+
def imap_path(name)
|
33
|
+
File.join(local_backup_path, name + ".imap")
|
34
|
+
end
|
35
|
+
|
36
|
+
def imap_content(name)
|
37
|
+
File.read(imap_path(name))
|
38
|
+
end
|
39
|
+
|
40
|
+
def imap_parsed(name)
|
41
|
+
JSON.parse(imap_content(name), symbolize_names: true)
|
42
|
+
end
|
23
43
|
end
|
24
44
|
|
25
45
|
RSpec.configure do |config|
|
@@ -1,18 +1,43 @@
|
|
1
1
|
module EmailServerHelpers
|
2
2
|
REQUESTED_ATTRIBUTES = ["RFC822", "FLAGS", "INTERNALDATE"]
|
3
|
+
DEFAULT_EMAIL = "address@example.org"
|
3
4
|
|
4
5
|
def send_email(folder, options)
|
5
|
-
|
6
|
+
message = message_as_server_message(options)
|
7
|
+
imap.append(folder, message, nil, nil)
|
8
|
+
end
|
9
|
+
|
10
|
+
def message_as_server_message(options)
|
11
|
+
from = options[:from] || DEFAULT_EMAIL
|
6
12
|
subject = options[:subject]
|
7
13
|
body = options[:body]
|
8
|
-
message = <<-EOT
|
14
|
+
message = <<-EOT.gsub("\n", "\r\n")
|
9
15
|
From: #{from}
|
10
16
|
Subject: #{subject}
|
11
17
|
|
12
18
|
#{body}
|
19
|
+
|
13
20
|
EOT
|
21
|
+
end
|
14
22
|
|
15
|
-
|
23
|
+
def server_messages(folder)
|
24
|
+
server_uids(folder).map do |uid|
|
25
|
+
server_fetch_email(folder, uid)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def server_message_to_body(message)
|
30
|
+
message["RFC822"]
|
31
|
+
end
|
32
|
+
|
33
|
+
def server_fetch_email(folder, uid)
|
34
|
+
examine folder
|
35
|
+
fetch_data_items = imap.uid_fetch([uid.to_i], REQUESTED_ATTRIBUTES)
|
36
|
+
return nil if fetch_data_items.nil?
|
37
|
+
fetch_data_item = fetch_data_items[0]
|
38
|
+
attributes = fetch_data_item.attr
|
39
|
+
attributes["RFC822"].force_encoding("utf-8")
|
40
|
+
attributes
|
16
41
|
end
|
17
42
|
|
18
43
|
def delete_emails(folder)
|
@@ -22,6 +47,44 @@ Subject: #{subject}
|
|
22
47
|
imap.expunge
|
23
48
|
end
|
24
49
|
|
50
|
+
def examine(folder)
|
51
|
+
imap.examine(folder)
|
52
|
+
end
|
53
|
+
|
54
|
+
def server_uids(folder)
|
55
|
+
examine(folder)
|
56
|
+
imap.uid_search(["ALL"]).sort
|
57
|
+
end
|
58
|
+
|
59
|
+
def server_uid_validity(folder)
|
60
|
+
examine(folder)
|
61
|
+
imap.responses["UIDVALIDITY"][0]
|
62
|
+
end
|
63
|
+
|
64
|
+
def server_folders
|
65
|
+
root_info = imap.list("", "")[0]
|
66
|
+
root = root_info.name
|
67
|
+
imap.list(root, "*")
|
68
|
+
end
|
69
|
+
|
70
|
+
def server_create_folder(folder)
|
71
|
+
imap.create(folder)
|
72
|
+
imap.disconnect
|
73
|
+
@imap = nil
|
74
|
+
end
|
75
|
+
|
76
|
+
def server_rename_folder(from, to)
|
77
|
+
imap.rename(from, to)
|
78
|
+
imap.disconnect
|
79
|
+
@imap = nil
|
80
|
+
end
|
81
|
+
|
82
|
+
def server_delete_folder(folder)
|
83
|
+
imap.delete folder
|
84
|
+
imap.disconnect
|
85
|
+
@imap = nil
|
86
|
+
end
|
87
|
+
|
25
88
|
def imap
|
26
89
|
@imap ||=
|
27
90
|
begin
|
@@ -1,6 +1,8 @@
|
|
1
1
|
shared_context "message-fixtures" do
|
2
2
|
let(:uid1) { 123 }
|
3
3
|
let(:uid2) { 345 }
|
4
|
+
let(:uid3) { 567 }
|
4
5
|
let(:msg1) { {uid: uid1, subject: "Test 1", body: "body 1\nHi"} }
|
5
6
|
let(:msg2) { {uid: uid2, subject: "Test 2", body: "body 2"} }
|
7
|
+
let(:msg3) { {uid: uid3, subject: "Test 3", body: "body 3"} }
|
6
8
|
end
|
@@ -10,7 +10,7 @@ describe Imap::Backup::Account::Connection do
|
|
10
10
|
end
|
11
11
|
|
12
12
|
let(:imap) do
|
13
|
-
|
13
|
+
instance_double(Net::IMAP, login: nil, disconnect: nil)
|
14
14
|
end
|
15
15
|
let(:imap_folders) { [] }
|
16
16
|
let(:options) do
|
@@ -86,7 +86,9 @@ describe Imap::Backup::Account::Connection do
|
|
86
86
|
end
|
87
87
|
|
88
88
|
context "#status" do
|
89
|
-
let(:folder)
|
89
|
+
let(:folder) do
|
90
|
+
instance_double(Imap::Backup::Account::Folder, uids: [remote_uid])
|
91
|
+
end
|
90
92
|
let(:local_uid) { "local_uid" }
|
91
93
|
let(:serializer) do
|
92
94
|
instance_double(Imap::Backup::Serializer::Mbox, uids: [local_uid])
|
@@ -112,9 +114,21 @@ describe Imap::Backup::Account::Connection do
|
|
112
114
|
end
|
113
115
|
|
114
116
|
context "#run_backup" do
|
115
|
-
let(:folder)
|
116
|
-
|
117
|
-
|
117
|
+
let(:folder) do
|
118
|
+
instance_double(
|
119
|
+
Imap::Backup::Account::Folder,
|
120
|
+
name: "folder",
|
121
|
+
uid_validity: uid_validity
|
122
|
+
)
|
123
|
+
end
|
124
|
+
let(:uid_validity) { 123 }
|
125
|
+
let(:serializer) do
|
126
|
+
instance_double(
|
127
|
+
Imap::Backup::Serializer::Mbox,
|
128
|
+
set_uid_validity: nil
|
129
|
+
)
|
130
|
+
end
|
131
|
+
let(:downloader) { instance_double(Imap::Backup::Downloader, run: nil) }
|
118
132
|
|
119
133
|
before do
|
120
134
|
allow(Imap::Backup::Downloader).
|
@@ -143,7 +143,8 @@ describe Imap::Backup::Configuration::Account do
|
|
143
143
|
context "if the server is blank" do
|
144
144
|
[
|
145
145
|
["GMail", "foo@gmail.com", "imap.gmail.com"],
|
146
|
-
["Fastmail", "bar@fastmail.fm", "
|
146
|
+
["Fastmail", "bar@fastmail.fm", "imap.fastmail.com"],
|
147
|
+
["Fastmail", "bar@fastmail.com", "imap.fastmail.com"]
|
147
148
|
].each do |service, email, expected|
|
148
149
|
context service do
|
149
150
|
let(:new_email) { email }
|
@@ -26,11 +26,7 @@ describe Email::Provider do
|
|
26
26
|
|
27
27
|
describe "#options" do
|
28
28
|
it "returns options" do
|
29
|
-
expect(subject.options).to
|
30
|
-
end
|
31
|
-
|
32
|
-
it "forces TLSv1_2" do
|
33
|
-
expect(subject.options[:ssl][:ssl_version]).to eq(:TLSv1_2)
|
29
|
+
expect(subject.options).to eq(port: 993, ssl: true)
|
34
30
|
end
|
35
31
|
end
|
36
32
|
|
@@ -1,119 +1,82 @@
|
|
1
|
-
require "spec_helper"
|
2
|
-
|
3
1
|
describe Imap::Backup::Serializer::Mbox do
|
4
|
-
let(:stat) { double("File::Stat", mode: 0o700) }
|
5
2
|
let(:base_path) { "/base/path" }
|
6
|
-
let(:
|
7
|
-
|
8
|
-
|
9
|
-
|
3
|
+
let(:store) do
|
4
|
+
instance_double(
|
5
|
+
Imap::Backup::Serializer::MboxStore,
|
6
|
+
add: nil,
|
7
|
+
uids: nil
|
8
|
+
)
|
9
|
+
end
|
10
|
+
let(:imap_folder) { "folder" }
|
11
|
+
let(:permissions) { 0o700 }
|
12
|
+
let(:dir_exists) { true }
|
10
13
|
|
11
14
|
before do
|
12
15
|
allow(Imap::Backup::Utils).to receive(:make_folder)
|
13
|
-
allow(
|
14
|
-
allow(
|
15
|
-
allow(File).to receive(:
|
16
|
-
allow(File).to receive(:exist?).with(imap_pathname).and_return(imap_exists)
|
16
|
+
allow(Imap::Backup::Utils).to receive(:mode) { permissions }
|
17
|
+
allow(Imap::Backup::Utils).to receive(:check_permissions) { true }
|
18
|
+
allow(File).to receive(:directory?) { dir_exists }
|
17
19
|
end
|
18
20
|
|
19
|
-
|
20
|
-
it "creates the containing directory" do
|
21
|
-
described_class.new(base_path, "my/folder")
|
21
|
+
subject { described_class.new(base_path, imap_folder) }
|
22
22
|
|
23
|
-
|
24
|
-
|
25
|
-
|
23
|
+
before do
|
24
|
+
allow(FileUtils).to receive(:chmod)
|
25
|
+
allow(Imap::Backup::Serializer::MboxStore).to receive(:new) { store }
|
26
|
+
end
|
26
27
|
|
27
|
-
|
28
|
-
|
29
|
-
let(:imap_exists) { false }
|
28
|
+
context "containing directory" do
|
29
|
+
before { subject.uids }
|
30
30
|
|
31
|
-
|
32
|
-
|
33
|
-
described_class.new(base_path, "my/folder")
|
34
|
-
}.to raise_error(RuntimeError, ".imap file missing")
|
35
|
-
end
|
36
|
-
end
|
31
|
+
context "when the IMAP folder has multiple elements" do
|
32
|
+
let(:imap_folder) { "folder/path" }
|
37
33
|
|
38
|
-
context "
|
39
|
-
let(:
|
34
|
+
context "when the containing directory is missing" do
|
35
|
+
let(:dir_exists) { false }
|
40
36
|
|
41
|
-
it "
|
42
|
-
expect
|
43
|
-
|
44
|
-
}.to raise_error(RuntimeError, ".mbox file missing")
|
37
|
+
it "is created" do
|
38
|
+
expect(Imap::Backup::Utils).to have_received(:make_folder).
|
39
|
+
with(base_path, File.dirname(imap_folder), 0o700)
|
45
40
|
end
|
46
41
|
end
|
47
42
|
end
|
48
|
-
end
|
49
43
|
|
50
|
-
|
51
|
-
|
44
|
+
context "when the containing directory permissons are incorrect" do
|
45
|
+
let(:permissions) { 0o777 }
|
52
46
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
subject { described_class.new(base_path, "my/folder") }
|
58
|
-
|
59
|
-
context "#uids" do
|
60
|
-
it "returns the backed-up uids as sorted integers" do
|
61
|
-
expect(subject.uids).to eq(ids.map(&:to_i).sort)
|
62
|
-
end
|
63
|
-
|
64
|
-
context "if the mbox does not exist" do
|
65
|
-
let(:mbox_exists) { false }
|
66
|
-
let(:imap_exists) { false }
|
67
|
-
|
68
|
-
it "returns an empty Array" do
|
69
|
-
expect(subject.uids).to eq([])
|
70
|
-
end
|
47
|
+
it "corrects them" do
|
48
|
+
path = File.expand_path(File.join(base_path, File.dirname(imap_folder)))
|
49
|
+
expect(FileUtils).to have_received(:chmod).with(0o700, path)
|
71
50
|
end
|
72
51
|
end
|
73
52
|
|
74
|
-
context "
|
75
|
-
|
76
|
-
|
77
|
-
let(:message) do
|
78
|
-
double("Email::Mboxrd::Message", to_serialized: mbox_formatted_message)
|
53
|
+
context "when the containing directory permissons are correct" do
|
54
|
+
it "does nothing" do
|
55
|
+
expect(FileUtils).to_not have_received(:chmod)
|
79
56
|
end
|
80
|
-
|
81
|
-
let(:imap_file) { double("File - imap", write: nil, close: nil) }
|
82
|
-
|
83
|
-
before do
|
84
|
-
allow(Email::Mboxrd::Message).to receive(:new).and_return(message)
|
85
|
-
allow(File).to receive(:open).with(mbox_pathname, "ab") { mbox_file }
|
86
|
-
allow(File).to receive(:open).with(imap_pathname, "ab") { imap_file }
|
87
|
-
end
|
88
|
-
|
89
|
-
it "saves the message to the mbox" do
|
90
|
-
subject.save(message_uid, "The\nemail\n")
|
57
|
+
end
|
91
58
|
|
92
|
-
|
59
|
+
context "when the containing directory exists" do
|
60
|
+
it "is not created" do
|
61
|
+
expect(Imap::Backup::Utils).to_not have_received(:make_folder).
|
62
|
+
with(base_path, File.dirname(imap_folder), 0o700)
|
93
63
|
end
|
64
|
+
end
|
65
|
+
end
|
94
66
|
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
expect(imap_file).to have_received(:write).with(message_uid + "\n")
|
99
|
-
end
|
67
|
+
context "#uids" do
|
68
|
+
it "calls the store" do
|
69
|
+
subject.uids
|
100
70
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
end
|
71
|
+
expect(store).to have_received(:uids)
|
72
|
+
end
|
73
|
+
end
|
105
74
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
end
|
75
|
+
context "#save" do
|
76
|
+
it "calls the store" do
|
77
|
+
subject.save("foo", "bar")
|
110
78
|
|
111
|
-
|
112
|
-
expect do
|
113
|
-
subject.save(message_uid, "The\nemail\n")
|
114
|
-
end.to_not raise_error
|
115
|
-
end
|
116
|
-
end
|
79
|
+
expect(store).to have_received(:add)
|
117
80
|
end
|
118
81
|
end
|
119
82
|
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
describe Imap::Backup::Serializer::MboxStore do
|
2
|
+
let(:base_path) { "/base/path" }
|
3
|
+
let(:folder) { "the/folder" }
|
4
|
+
let(:folder_path) { File.join(base_path, folder) }
|
5
|
+
let(:imap_pathname) { folder_path + ".imap" }
|
6
|
+
let(:imap_exists) { true }
|
7
|
+
let(:imap_file) { double("File - imap", write: nil, close: nil) }
|
8
|
+
let(:mbox_pathname) { folder_path + ".mbox" }
|
9
|
+
let(:mbox_exists) { true }
|
10
|
+
let(:mbox_file) { double("File - mbox", write: nil, close: nil) }
|
11
|
+
let(:uids) { [3, 2, 1] }
|
12
|
+
let(:imap_content) do
|
13
|
+
{
|
14
|
+
version: Imap::Backup::Serializer::MboxStore::CURRENT_VERSION,
|
15
|
+
uid_validity: 123,
|
16
|
+
uids: uids.sort
|
17
|
+
}.to_json
|
18
|
+
end
|
19
|
+
|
20
|
+
subject { described_class.new(base_path, folder) }
|
21
|
+
|
22
|
+
before do
|
23
|
+
allow(File).to receive(:exist?).and_call_original
|
24
|
+
allow(File).to receive(:exist?).with(imap_pathname) { imap_exists }
|
25
|
+
allow(File).to receive(:exist?).with(mbox_pathname) { mbox_exists }
|
26
|
+
|
27
|
+
allow(File).to receive(:open).and_call_original
|
28
|
+
allow(File).
|
29
|
+
to receive(:open).with("/base/path/my/folder.imap") { imap_content }
|
30
|
+
allow(File).to receive(:open).with(imap_pathname, "w").and_yield(imap_file)
|
31
|
+
allow(File).to receive(:open).with(mbox_pathname, "w").and_yield(mbox_file)
|
32
|
+
|
33
|
+
allow(File).to receive(:read).and_call_original
|
34
|
+
allow(File).to receive(:read).with(imap_pathname) { imap_content }
|
35
|
+
|
36
|
+
allow(File).to receive(:unlink).and_call_original
|
37
|
+
allow(File).to receive(:unlink).with(imap_pathname)
|
38
|
+
allow(File).to receive(:unlink).with(mbox_pathname)
|
39
|
+
|
40
|
+
allow(FileUtils).to receive(:chmod)
|
41
|
+
end
|
42
|
+
|
43
|
+
context "#uids" do
|
44
|
+
it "returns the backed-up uids as sorted integers" do
|
45
|
+
expect(subject.uids).to eq(uids.map(&:to_i).sort)
|
46
|
+
end
|
47
|
+
|
48
|
+
context "when the imap file does not exist" do
|
49
|
+
let(:imap_exists) { false }
|
50
|
+
|
51
|
+
it "returns an empty Array" do
|
52
|
+
expect(subject.uids).to eq([])
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
context "when the mbox does not exist" do
|
57
|
+
let(:mbox_exists) { false }
|
58
|
+
|
59
|
+
it "returns an empty Array" do
|
60
|
+
expect(subject.uids).to eq([])
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
context "#add" do
|
66
|
+
let(:mbox_formatted_message) { "message in mbox format" }
|
67
|
+
let(:message_uid) { "999" }
|
68
|
+
let(:message) do
|
69
|
+
instance_double(
|
70
|
+
Email::Mboxrd::Message,
|
71
|
+
to_serialized: mbox_formatted_message
|
72
|
+
)
|
73
|
+
end
|
74
|
+
let(:updated_imap_content) do
|
75
|
+
{
|
76
|
+
version: Imap::Backup::Serializer::MboxStore::CURRENT_VERSION,
|
77
|
+
uid_validity: 123,
|
78
|
+
uids: (uids + [999]).sort
|
79
|
+
}.to_json
|
80
|
+
end
|
81
|
+
|
82
|
+
before do
|
83
|
+
allow(Email::Mboxrd::Message).to receive(:new).and_return(message)
|
84
|
+
allow(File).to receive(:open).with(mbox_pathname, "ab") { mbox_file }
|
85
|
+
subject.uid_validity = 123
|
86
|
+
end
|
87
|
+
|
88
|
+
it "saves the message to the mbox" do
|
89
|
+
subject.add(message_uid, "The\nemail\n")
|
90
|
+
|
91
|
+
expect(mbox_file).to have_received(:write).with(mbox_formatted_message)
|
92
|
+
end
|
93
|
+
|
94
|
+
it "saves the uid to the imap file" do
|
95
|
+
subject.add(message_uid, "The\nemail\n")
|
96
|
+
|
97
|
+
expect(imap_file).to have_received(:write).with(updated_imap_content)
|
98
|
+
end
|
99
|
+
|
100
|
+
context "when the message causes parsing errors" do
|
101
|
+
before do
|
102
|
+
allow(message).to receive(:to_serialized).and_raise(ArgumentError)
|
103
|
+
end
|
104
|
+
|
105
|
+
it "skips the message" do
|
106
|
+
subject.add(message_uid, "The\nemail\n")
|
107
|
+
expect(mbox_file).to_not have_received(:write)
|
108
|
+
end
|
109
|
+
|
110
|
+
it "does not fail" do
|
111
|
+
expect do
|
112
|
+
subject.add(message_uid, "The\nemail\n")
|
113
|
+
end.to_not raise_error
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|