imap-backup 1.3.0 → 1.4.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/.gitignore +1 -0
- data/.rspec +3 -0
- data/.rubocop.yml +2 -1
- data/.travis.yml +1 -1
- data/README.md +12 -0
- data/bin/imap-backup +11 -6
- data/docker-compose.yml +15 -0
- data/imap-backup.gemspec +3 -2
- data/lib/email/mboxrd/message.rb +30 -4
- data/lib/email/provider.rb +2 -2
- data/lib/imap/backup.rb +0 -1
- data/lib/imap/backup/account/connection.rb +29 -15
- data/lib/imap/backup/account/folder.rb +3 -3
- data/lib/imap/backup/configuration/account.rb +15 -12
- data/lib/imap/backup/configuration/asker.rb +3 -1
- data/lib/imap/backup/configuration/folder_chooser.rb +5 -2
- data/lib/imap/backup/configuration/list.rb +12 -9
- data/lib/imap/backup/configuration/setup.rb +5 -3
- data/lib/imap/backup/configuration/store.rb +4 -4
- data/lib/imap/backup/downloader.rb +6 -2
- data/lib/imap/backup/serializer/base.rb +2 -2
- data/lib/imap/backup/serializer/mbox.rb +16 -7
- data/lib/imap/backup/utils.rb +8 -3
- data/lib/imap/backup/version.rb +1 -1
- data/spec/features/backup_spec.rb +27 -0
- data/spec/features/helper.rb +2 -0
- data/spec/features/support/backup_directory.rb +27 -0
- data/spec/features/support/email_server.rb +40 -0
- data/spec/features/support/shared/connection_context.rb +12 -0
- data/spec/features/support/shared/message_fixtures.rb +6 -0
- data/spec/fixtures/connection.yml +7 -0
- data/spec/support/fixtures.rb +6 -0
- data/spec/unit/account/connection_spec.rb +19 -7
- data/spec/unit/account/folder_spec.rb +15 -5
- data/spec/unit/configuration/account_spec.rb +18 -8
- data/spec/unit/configuration/asker_spec.rb +6 -3
- data/spec/unit/configuration/connection_tester_spec.rb +1 -1
- data/spec/unit/configuration/folder_chooser_spec.rb +21 -11
- data/spec/unit/configuration/list_spec.rb +13 -6
- data/spec/unit/configuration/setup_spec.rb +5 -3
- data/spec/unit/configuration/store_spec.rb +13 -8
- data/spec/unit/downloader_spec.rb +3 -1
- data/spec/unit/email/mboxrd/message_spec.rb +32 -13
- data/spec/unit/email/provider_spec.rb +7 -3
- data/spec/unit/serializer/base_spec.rb +3 -2
- data/spec/unit/serializer/mbox_spec.rb +9 -6
- data/spec/unit/utils_spec.rb +15 -13
- metadata +35 -5
- data/lib/imap/backup/serializer/directory.rb +0 -43
- data/spec/unit/serializer/directory_spec.rb +0 -69
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4665c4dcce3e9de07be72563f7f52045789d1da30120462e290ae5c3207598db
|
4
|
+
data.tar.gz: 3f455617d7b7a0d392856b74adb0aa75e528037e8716469a72e3a8b9aa405841
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 57ef0a8b879f915630ba3d3ecbf339973ecf11a0eb0ae30b1e520bd346a9daaaab501fc90c509e0f1ad5d4b0fbee6cf6a4424da3b10caeb72bc52b96663bc9cf
|
7
|
+
data.tar.gz: f834d433145dcdc60ece0b3e03e973d36c0bb884060e13378922a886310e9362f92db901a4a9882e7a31a300594253377c7450085a5af66097646c6480cd2ed7
|
data/.gitignore
CHANGED
data/.rspec
ADDED
data/.rubocop.yml
CHANGED
@@ -1,6 +1,7 @@
|
|
1
|
-
inherit_from: https://
|
1
|
+
inherit_from: https://gitlab.com/snippets/1744945/raw
|
2
2
|
|
3
3
|
AllCops:
|
4
|
+
TargetRubyVersion: 2.1
|
4
5
|
Exclude:
|
5
6
|
- "bin/stubs/*"
|
6
7
|
DisplayCopNames:
|
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -186,6 +186,18 @@ $ imap-backup status
|
|
186
186
|
* https://github.com/rgrove/larch - copies between IMAP servers
|
187
187
|
* https://github.com/OfflineIMAP/offlineimap
|
188
188
|
|
189
|
+
# Testing
|
190
|
+
|
191
|
+
## Integration Tests
|
192
|
+
|
193
|
+
Integration tests are run against a Docker image
|
194
|
+
(antespi/docker-imap-devel:latest).
|
195
|
+
|
196
|
+
Currently, the integration tests with Docker are excluded from the CI run.
|
197
|
+
|
198
|
+
The image has a pre-existing user:
|
199
|
+
`address@example.org` with password `pass`
|
200
|
+
|
189
201
|
## Contributing
|
190
202
|
|
191
203
|
1. Fork it
|
data/bin/imap-backup
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
require "optparse"
|
3
3
|
|
4
|
-
$LOAD_PATH.unshift(File.expand_path("
|
4
|
+
$LOAD_PATH.unshift(File.expand_path("../lib/", __dir__))
|
5
5
|
require "imap/backup"
|
6
6
|
|
7
7
|
KNOWN_COMMANDS = [
|
@@ -10,21 +10,26 @@ KNOWN_COMMANDS = [
|
|
10
10
|
{name: "folders", help: "List folders for all (or selected) accounts"},
|
11
11
|
{name: "status", help: "List count of non backed-up emails per folder"},
|
12
12
|
{name: "help", help: "Show usage"}
|
13
|
-
]
|
13
|
+
].freeze
|
14
14
|
|
15
15
|
options = {command: "backup"}
|
16
|
-
|
16
|
+
parser = OptionParser.new do |opts|
|
17
17
|
opts.banner = "Usage: #{$PROGRAM_NAME} [options] COMMAND"
|
18
18
|
|
19
19
|
opts.separator ""
|
20
20
|
opts.separator "Commands:"
|
21
21
|
KNOWN_COMMANDS.each do |command|
|
22
|
-
opts.separator "\t%-
|
22
|
+
opts.separator format("\t%- 20<name>s %<help>s", command)
|
23
23
|
end
|
24
24
|
opts.separator ""
|
25
25
|
opts.separator "Common options:"
|
26
26
|
|
27
|
-
opts.on(
|
27
|
+
opts.on(
|
28
|
+
"-a",
|
29
|
+
"--accounts ACCOUNT1[,ACCOUNT2,...]",
|
30
|
+
Array,
|
31
|
+
"only these accounts"
|
32
|
+
) do |account|
|
28
33
|
options[:accounts] = account
|
29
34
|
end
|
30
35
|
|
@@ -38,7 +43,7 @@ opts = OptionParser.new do |opts|
|
|
38
43
|
exit
|
39
44
|
end
|
40
45
|
end
|
41
|
-
|
46
|
+
parser.parse!
|
42
47
|
|
43
48
|
if ARGV.size > 0
|
44
49
|
options[:command] = ARGV.shift
|
data/docker-compose.yml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# This file adapted from github.com/antespi/docker-imap-devel
|
2
|
+
version: "3"
|
3
|
+
|
4
|
+
services:
|
5
|
+
imap:
|
6
|
+
image: antespi/docker-imap-devel:latest
|
7
|
+
container_name: imap
|
8
|
+
ports:
|
9
|
+
- "8025:25"
|
10
|
+
- "8143:143"
|
11
|
+
- "8993:993"
|
12
|
+
environment:
|
13
|
+
- MAILNAME=example.org
|
14
|
+
- MAIL_ADDRESS=address@example.org
|
15
|
+
- MAIL_PASS=pass
|
data/imap-backup.gemspec
CHANGED
@@ -3,8 +3,8 @@ require "imap/backup/version"
|
|
3
3
|
|
4
4
|
Gem::Specification.new do |gem|
|
5
5
|
gem.name = "imap-backup"
|
6
|
-
gem.description =
|
7
|
-
gem.summary =
|
6
|
+
gem.description = "Backup GMail, or any other IMAP email service, to disk."
|
7
|
+
gem.summary = "Backup GMail (or other IMAP) accounts to disk"
|
8
8
|
gem.authors = ["Joe Yates"]
|
9
9
|
gem.email = ["joe.g.yates@gmail.com"]
|
10
10
|
gem.homepage = "https://github.com/joeyates/imap-backup"
|
@@ -20,6 +20,7 @@ Gem::Specification.new do |gem|
|
|
20
20
|
gem.add_runtime_dependency "mail"
|
21
21
|
|
22
22
|
gem.add_development_dependency "codeclimate-test-reporter", "~> 0.4.8"
|
23
|
+
gem.add_development_dependency "pry-byebug"
|
23
24
|
gem.add_development_dependency "rspec", ">= 3.0.0"
|
24
25
|
gem.add_development_dependency "rubocop-rspec"
|
25
26
|
gem.add_development_dependency "simplecov"
|
data/lib/email/mboxrd/message.rb
CHANGED
@@ -6,12 +6,22 @@ module Email::Mboxrd
|
|
6
6
|
class Message
|
7
7
|
attr_reader :supplied_body
|
8
8
|
|
9
|
+
def self.from_serialized(serialized)
|
10
|
+
cleaned = serialized.gsub(/^>(>*From)/, "\\1")
|
11
|
+
# Serialized messages in this format *should* start with a line
|
12
|
+
# From xxx yy zz
|
13
|
+
if cleaned.start_with?("From ")
|
14
|
+
cleaned = cleaned.sub(/^From .*[\r\n]*/, "")
|
15
|
+
end
|
16
|
+
new(cleaned)
|
17
|
+
end
|
18
|
+
|
9
19
|
def initialize(supplied_body)
|
10
20
|
@supplied_body = supplied_body.clone
|
11
21
|
@supplied_body.force_encoding("binary")
|
12
22
|
end
|
13
23
|
|
14
|
-
def
|
24
|
+
def to_serialized
|
15
25
|
"From " + from + "\n" + mboxrd_body + "\n"
|
16
26
|
end
|
17
27
|
|
@@ -36,24 +46,40 @@ module Email::Mboxrd
|
|
36
46
|
end
|
37
47
|
|
38
48
|
def from
|
39
|
-
|
49
|
+
@from ||=
|
50
|
+
begin
|
51
|
+
from = best_from
|
52
|
+
from << " " + asctime if asctime != ""
|
53
|
+
from
|
54
|
+
end
|
40
55
|
end
|
41
56
|
|
42
57
|
def mboxrd_body
|
43
58
|
@mboxrd_body ||=
|
44
59
|
begin
|
45
|
-
@mboxrd_body = supplied_body
|
60
|
+
@mboxrd_body = add_extra_quote(supplied_body)
|
46
61
|
@mboxrd_body += "\n" if !@mboxrd_body.end_with?("\n")
|
47
62
|
@mboxrd_body
|
48
63
|
end
|
49
64
|
end
|
50
65
|
|
66
|
+
def add_extra_quote(body)
|
67
|
+
# The mboxrd format requires that lines starting with 'From'
|
68
|
+
# be prefixed with a '>' so that any remaining lines which start with
|
69
|
+
# 'From ' can be taken as the beginning of messages.
|
70
|
+
# http://www.digitalpreservation.gov/formats/fdd/fdd000385.shtml
|
71
|
+
# Here we add an extra '>' before any "From" or ">From".
|
72
|
+
body.gsub(/\n(>*From)/, "\n>\\1")
|
73
|
+
end
|
74
|
+
|
51
75
|
def asctime
|
52
|
-
date ? date.asctime : ""
|
76
|
+
@asctime ||= date ? date.asctime : ""
|
53
77
|
end
|
54
78
|
|
55
79
|
def date
|
56
80
|
parsed.date
|
81
|
+
rescue
|
82
|
+
nil
|
57
83
|
end
|
58
84
|
end
|
59
85
|
end
|
data/lib/email/provider.rb
CHANGED
@@ -2,7 +2,7 @@ module Email; end
|
|
2
2
|
|
3
3
|
class Email::Provider
|
4
4
|
def self.for_address(address)
|
5
|
-
case
|
5
|
+
case
|
6
6
|
when address.end_with?("@gmail.com")
|
7
7
|
new(:gmail)
|
8
8
|
when address.end_with?("@fastmail.fm")
|
@@ -21,7 +21,7 @@ class Email::Provider
|
|
21
21
|
def options
|
22
22
|
case provider
|
23
23
|
when :gmail
|
24
|
-
{port: 993, ssl:
|
24
|
+
{port: 993, ssl: {ssl_version: :TLSv1_2}}
|
25
25
|
when :fastmail
|
26
26
|
{port: 993, ssl: true}
|
27
27
|
else
|
data/lib/imap/backup.rb
CHANGED
@@ -12,7 +12,6 @@ require "imap/backup/configuration/setup"
|
|
12
12
|
require "imap/backup/configuration/store"
|
13
13
|
require "imap/backup/downloader"
|
14
14
|
require "imap/backup/serializer/base"
|
15
|
-
require "imap/backup/serializer/directory"
|
16
15
|
require "imap/backup/serializer/mbox"
|
17
16
|
require "imap/backup/version"
|
18
17
|
require "email/provider"
|
@@ -4,9 +4,10 @@ module Imap::Backup
|
|
4
4
|
module Account; end
|
5
5
|
|
6
6
|
class Account::Connection
|
7
|
-
attr_reader :username
|
8
|
-
attr_reader :local_path
|
9
7
|
attr_reader :connection_options
|
8
|
+
attr_reader :local_path
|
9
|
+
attr_reader :password
|
10
|
+
attr_reader :username
|
10
11
|
|
11
12
|
def initialize(options)
|
12
13
|
@username, @password = options[:username], options[:password]
|
@@ -19,18 +20,23 @@ module Imap::Backup
|
|
19
20
|
end
|
20
21
|
|
21
22
|
def folders
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
23
|
+
@folders ||=
|
24
|
+
begin
|
25
|
+
root = provider_root
|
26
|
+
@folders = imap.list(root, "*")
|
27
|
+
if @folders.nil?
|
28
|
+
Imap::Backup.logger.warn(
|
29
|
+
"Unable to get folder list for account #{username}"
|
30
|
+
)
|
31
|
+
end
|
32
|
+
@folders
|
33
|
+
end
|
28
34
|
end
|
29
35
|
|
30
36
|
def status
|
31
37
|
backup_folders.map do |folder|
|
32
38
|
f = Account::Folder.new(self, folder[:name])
|
33
|
-
s = Serializer::
|
39
|
+
s = Serializer::Mbox.new(local_path, folder[:name])
|
34
40
|
{name: folder[:name], local: s.uids, remote: f.uids}
|
35
41
|
end
|
36
42
|
end
|
@@ -52,7 +58,9 @@ module Imap::Backup
|
|
52
58
|
def imap
|
53
59
|
return @imap unless @imap.nil?
|
54
60
|
options = provider_options
|
55
|
-
Imap::Backup.logger.debug
|
61
|
+
Imap::Backup.logger.debug(
|
62
|
+
"Creating IMAP instance: #{server}, options: #{options.inspect}"
|
63
|
+
)
|
56
64
|
@imap = Net::IMAP.new(server, options)
|
57
65
|
Imap::Backup.logger.debug "Logging in: #{username}/#{masked_password}"
|
58
66
|
@imap.login(username, password)
|
@@ -78,14 +86,10 @@ module Imap::Backup
|
|
78
86
|
)
|
79
87
|
end
|
80
88
|
|
81
|
-
def password
|
82
|
-
@password
|
83
|
-
end
|
84
|
-
|
85
89
|
def masked_password
|
86
90
|
password.gsub(/./, "x")
|
87
91
|
end
|
88
|
-
|
92
|
+
|
89
93
|
def backup_folders
|
90
94
|
return @backup_folders if @backup_folders && (@backup_folders.size > 0)
|
91
95
|
(folders || []).map { |f| {name: f.name} }
|
@@ -104,5 +108,15 @@ module Imap::Backup
|
|
104
108
|
def provider_options
|
105
109
|
provider.options.merge(connection_options)
|
106
110
|
end
|
111
|
+
|
112
|
+
# 6.3.8. LIST Command
|
113
|
+
# An empty ("" string) mailbox name argument is a special request to
|
114
|
+
# return the hierarchy delimiter and the root name of the name given
|
115
|
+
# in the reference.
|
116
|
+
def provider_root
|
117
|
+
return @provider_root if @provider_root
|
118
|
+
root_info = imap.list("", "")[0]
|
119
|
+
@provider_root = root_info.name
|
120
|
+
end
|
107
121
|
end
|
108
122
|
end
|
@@ -6,7 +6,7 @@ module Imap::Backup
|
|
6
6
|
class Account::Folder
|
7
7
|
extend Forwardable
|
8
8
|
|
9
|
-
REQUESTED_ATTRIBUTES = ["RFC822", "FLAGS", "INTERNALDATE"]
|
9
|
+
REQUESTED_ATTRIBUTES = ["RFC822", "FLAGS", "INTERNALDATE"].freeze
|
10
10
|
|
11
11
|
attr_reader :connection
|
12
12
|
attr_reader :name
|
@@ -25,7 +25,7 @@ module Imap::Backup
|
|
25
25
|
def uids
|
26
26
|
imap.examine(name)
|
27
27
|
imap.uid_search(["ALL"]).sort
|
28
|
-
rescue Net::IMAP::NoResponseError
|
28
|
+
rescue Net::IMAP::NoResponseError
|
29
29
|
Imap::Backup.logger.warn "Folder '#{name}' does not exist"
|
30
30
|
[]
|
31
31
|
end
|
@@ -38,7 +38,7 @@ module Imap::Backup
|
|
38
38
|
attributes = fetch_data_item.attr
|
39
39
|
attributes["RFC822"].force_encoding("utf-8")
|
40
40
|
attributes
|
41
|
-
rescue Net::IMAP::NoResponseError
|
41
|
+
rescue Net::IMAP::NoResponseError
|
42
42
|
Imap::Backup.logger.warn "Folder '#{name}' does not exist"
|
43
43
|
nil
|
44
44
|
end
|
@@ -33,21 +33,22 @@ module Imap::Backup
|
|
33
33
|
end
|
34
34
|
|
35
35
|
def header(menu)
|
36
|
-
menu.header = <<-
|
37
|
-
Account:
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
36
|
+
menu.header = <<-HEADER.gsub(/^\s{8}/m, "")
|
37
|
+
Account:
|
38
|
+
email: #{account[:username]}
|
39
|
+
server: #{account[:server]}
|
40
|
+
path: #{account[:local_path]}
|
41
|
+
folders: #{folders.map { |f| f[:name] }.join(', ')}
|
42
|
+
password: #{masked_password}
|
43
|
+
HEADER
|
44
44
|
end
|
45
45
|
|
46
46
|
def modify_email(menu)
|
47
47
|
menu.choice("modify email") do
|
48
48
|
username = Configuration::Asker.email(username)
|
49
49
|
puts "username: #{username}"
|
50
|
-
|
50
|
+
other_accounts = store.accounts.reject { |a| a == account }
|
51
|
+
others = other_accounts.map { |a| a[:username] }
|
51
52
|
puts "others: #{others.inspect}"
|
52
53
|
if others.include?(username)
|
53
54
|
puts "There is already an account set up with that email address"
|
@@ -83,19 +84,21 @@ Account:
|
|
83
84
|
|
84
85
|
def modify_backup_path(menu)
|
85
86
|
menu.choice("modify backup path") do
|
86
|
-
validator =
|
87
|
+
validator = ->(p) do
|
87
88
|
same = store.accounts.find do |a|
|
88
89
|
a[:username] != account[:username] && a[:local_path] == p
|
89
90
|
end
|
90
91
|
if same
|
91
|
-
puts "The path '#{p}' is used to backup
|
92
|
+
puts "The path '#{p}' is used to backup " \
|
93
|
+
"the account '#{same[:username]}'"
|
92
94
|
false
|
93
95
|
else
|
94
96
|
true
|
95
97
|
end
|
96
98
|
end
|
97
99
|
existing = account[:local_path].clone
|
98
|
-
account[:local_path] =
|
100
|
+
account[:local_path] =
|
101
|
+
Configuration::Asker.backup_path(account[:local_path], validator)
|
99
102
|
account[:modified] = true if existing != account[:local_path]
|
100
103
|
end
|
101
104
|
end
|
@@ -21,7 +21,9 @@ module Imap::Backup
|
|
21
21
|
password = highline.ask("password: ") { |q| q.echo = false }
|
22
22
|
confirmation = highline.ask("repeat password: ") { |q| q.echo = false }
|
23
23
|
if password != confirmation
|
24
|
-
return nil
|
24
|
+
return nil if !highline.agree(
|
25
|
+
"the password and confirmation did not match.\nContinue? (y/n) "
|
26
|
+
)
|
25
27
|
return self.password
|
26
28
|
end
|
27
29
|
password
|
@@ -52,7 +52,9 @@ module Imap::Backup
|
|
52
52
|
end
|
53
53
|
|
54
54
|
def is_selected?(folder_name)
|
55
|
-
account[:folders]
|
55
|
+
backup_folders = account[:folders]
|
56
|
+
return false if backup_folders.nil?
|
57
|
+
backup_folders.find { |f| f[:name] == folder_name }
|
56
58
|
end
|
57
59
|
|
58
60
|
def toggle_selection(folder_name)
|
@@ -60,6 +62,7 @@ module Imap::Backup
|
|
60
62
|
changed = account[:folders].reject! { |f| f[:name] == folder_name }
|
61
63
|
account[:modified] = true if changed
|
62
64
|
else
|
65
|
+
account[:folders] ||= []
|
63
66
|
account[:folders] << {name: folder_name}
|
64
67
|
account[:modified] = true
|
65
68
|
end
|
@@ -67,7 +70,7 @@ module Imap::Backup
|
|
67
70
|
|
68
71
|
def connection
|
69
72
|
@connection ||= Account::Connection.new(account)
|
70
|
-
rescue
|
73
|
+
rescue
|
71
74
|
nil
|
72
75
|
end
|
73
76
|
|