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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37b1f605db79f10d3520f0f718e457d38c839003380f53fc7c1259a16293952e
4
- data.tar.gz: e13a7ab06d0c3a9e93ef52709de8f23a6fe4eaba720f51a3535aa2b984468df5
3
+ metadata.gz: 3f897dc0bac78683c9cae100718a03cf934adc0348866a4688ce5d2196bbdcfa
4
+ data.tar.gz: 164bc47c5acb9e448b3c61e6d58d18c3f48cd03fd6a2cf236682e5f5abd11232
5
5
  SHA512:
6
- metadata.gz: 9961d6f0ddde8b511f77d1cf0a1ebec932d2a98a0b6762c18ea36496365125b451818c1d9565527b146ccb8a7c495e17d6f590600caee8aa7a02d128edf8fe19
7
- data.tar.gz: 511c7025476ff56508d6538bc74ba382d2af741ba1c2ee8eabf36ceb6ce002b9d18ef51fc731d1aed4e8f20cfbeb998c3ccd39000528cccaa45c365403ffea3f
6
+ metadata.gz: 1ef7c3cea9a72c2fec7f87491c80eebc1aeeeca50c207fec4c713259a2de34c986a7a2457aed7cc20b36e4173f52fd9d30ac0340bca64600d23792d00433ac3f
7
+ data.tar.gz: 787c00650b5dd72258e895f51457599e0c20bd4d5897c5ec8c812fd4a54ce96796538b8c88cc689a02365f8fed5122237724a566aae9849dd5b807804a3a644f
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/README.md CHANGED
@@ -16,6 +16,12 @@
16
16
  [Rubygem]: http://rubygems.org/gems/imap-backup "Ruby gem at rubygems.org"
17
17
  [Continuous Integration]: http://travis-ci.org/joeyates/imap-backup "Build status by Travis-CI"
18
18
 
19
+ ## Version 2
20
+
21
+ With versions above 2.x, this gems stores IMAP metadata in a
22
+ backwardly-incompatible way. When upgrading, all old backups will be gradually
23
+ deleted to allow for the new file format to be introduced.
24
+
19
25
  # Installation
20
26
 
21
27
  ```shell
@@ -158,6 +164,13 @@ If you have problems:
158
164
  "debug": true
159
165
  }
160
166
  ```
167
+ # Restore
168
+
169
+ All missing messages are pushed to the IMAP server.
170
+ Existing messages are left unchanged.
171
+
172
+ This functionality requires that the IMAP server supports the UIDPLUS
173
+ extension to IMAP4.
161
174
 
162
175
  # Other Usage
163
176
 
@@ -190,13 +203,33 @@ $ imap-backup status
190
203
 
191
204
  ## Integration Tests
192
205
 
193
- Integration tests are run against a Docker image
206
+ Integration tests (feature specs) are run against a Docker image
194
207
  (antespi/docker-imap-devel:latest).
195
208
 
209
+ In one shell, run the Docker image:
210
+
211
+ ```
212
+ docker run \
213
+ --env MAIL_ADDRESS=address@example.org \
214
+ --env MAIL_PASS=pass \
215
+ --env MAILNAME=example.org \
216
+ --publish 8993:993 \
217
+ antespi/docker-imap-devel:latest
218
+ ```
219
+
196
220
  Currently, the integration tests with Docker are excluded from the CI run.
197
221
 
198
- The image has a pre-existing user:
199
- `address@example.org` with password `pass`
222
+ To run **just** the Docker-based tests:
223
+
224
+ ```
225
+ rspec -t docker
226
+ ```
227
+
228
+ To run **all** specs, including the integration tests, do the following:
229
+
230
+ ```
231
+ rspec -O .rspec-all
232
+ ```
200
233
 
201
234
  ## Contributing
202
235
 
@@ -8,6 +8,7 @@ KNOWN_COMMANDS = [
8
8
  {name: "setup", help: "Create/edit the configuration file"},
9
9
  {name: "backup", help: "Do the backup (default)"},
10
10
  {name: "folders", help: "List folders for all (or selected) accounts"},
11
+ {name: "restore", help: "Restore emails to server"},
11
12
  {name: "status", help: "List count of non backed-up emails per folder"},
12
13
  {name: "help", help: "Show usage"}
13
14
  ].freeze
@@ -84,6 +85,10 @@ when "folders"
84
85
  end
85
86
  folders.each { |f| puts "\t" + f.name }
86
87
  end
88
+ when "restore"
89
+ configuration.each_connection do |connection|
90
+ connection.restore
91
+ end
87
92
  when "status"
88
93
  configuration.each_connection do |connection|
89
94
  puts connection.username
@@ -15,6 +15,13 @@ Gem::Specification.new do |gem|
15
15
  gem.require_paths = ["lib"]
16
16
  gem.version = Imap::Backup::VERSION
17
17
 
18
+ gem.post_install_message = <<-MESSAGE.gsub(/^\s{4}/m, "")
19
+ When upgrading #{gem.name} from version 1.x to 2.x not that the
20
+ metadata storage method has changed (from flat file to JSON).
21
+ As a result, on the first run after an upgrade, old backup folders will be
22
+ **deleted** and a full new backup created.
23
+ MESSAGE
24
+
18
25
  gem.add_runtime_dependency "rake"
19
26
  gem.add_runtime_dependency "highline"
20
27
  gem.add_runtime_dependency "mail"
@@ -22,7 +22,15 @@ module Email::Mboxrd
22
22
  end
23
23
 
24
24
  def to_serialized
25
- "From " + from + "\n" + mboxrd_body + "\n"
25
+ "From " + from + "\n" + mboxrd_body
26
+ end
27
+
28
+ def date
29
+ parsed.date
30
+ end
31
+
32
+ def imap_body
33
+ supplied_body.gsub(/(?<!\r)\n/, "\r\n")
26
34
  end
27
35
 
28
36
  private
@@ -57,9 +65,10 @@ module Email::Mboxrd
57
65
  def mboxrd_body
58
66
  @mboxrd_body ||=
59
67
  begin
60
- @mboxrd_body = add_extra_quote(supplied_body)
61
- @mboxrd_body += "\n" if !@mboxrd_body.end_with?("\n")
62
- @mboxrd_body
68
+ mboxrd_body = add_extra_quote(supplied_body.gsub("\r\n", "\n"))
69
+ mboxrd_body += "\n" if !mboxrd_body.end_with?("\n")
70
+ mboxrd_body += "\n" if !mboxrd_body.end_with?("\n\n")
71
+ mboxrd_body
63
72
  end
64
73
  end
65
74
 
@@ -75,11 +84,5 @@ module Email::Mboxrd
75
84
  def asctime
76
85
  @asctime ||= date ? date.asctime : ""
77
86
  end
78
-
79
- def date
80
- parsed.date
81
- rescue
82
- nil
83
- end
84
87
  end
85
88
  end
@@ -3,6 +3,8 @@ module Email; end
3
3
  class Email::Provider
4
4
  def self.for_address(address)
5
5
  case
6
+ when address.end_with?("@fastmail.com")
7
+ new(:fastmail)
6
8
  when address.end_with?("@gmail.com")
7
9
  new(:gmail)
8
10
  when address.end_with?("@fastmail.fm")
@@ -19,7 +21,14 @@ class Email::Provider
19
21
  end
20
22
 
21
23
  def options
22
- {port: 993, ssl: {ssl_version: :TLSv1_2}}
24
+ case provider
25
+ when :gmail
26
+ {port: 993, ssl: true}
27
+ when :fastmail
28
+ {port: 993, ssl: true}
29
+ else
30
+ {port: 993, ssl: true}
31
+ end
23
32
  end
24
33
 
25
34
  def host
@@ -27,7 +36,7 @@ class Email::Provider
27
36
  when :gmail
28
37
  "imap.gmail.com"
29
38
  when :fastmail
30
- "mail.messagingengine.com"
39
+ "imap.fastmail.com"
31
40
  end
32
41
  end
33
42
  end
@@ -11,7 +11,8 @@ require "imap/backup/configuration/list"
11
11
  require "imap/backup/configuration/setup"
12
12
  require "imap/backup/configuration/store"
13
13
  require "imap/backup/downloader"
14
- require "imap/backup/serializer/base"
14
+ require "imap/backup/uploader"
15
+ require "imap/backup/serializer"
15
16
  require "imap/backup/serializer/mbox"
16
17
  require "imap/backup/version"
17
18
  require "email/provider"
@@ -47,14 +47,44 @@ module Imap::Backup
47
47
  imap
48
48
  each_folder do |folder, serializer|
49
49
  Imap::Backup.logger.debug "[#{folder.name}] running backup"
50
+ serializer.set_uid_validity(folder.uid_validity)
50
51
  Downloader.new(folder, serializer).run
51
52
  end
52
53
  end
53
54
 
55
+ def restore
56
+ local_folders do |serializer, folder|
57
+ exists = folder.exist?
58
+ if exists
59
+ new_name = serializer.set_uid_validity(folder.uid_validity)
60
+ old_name = serializer.folder
61
+ if new_name
62
+ Imap::Backup.logger.debug "Backup '#{old_name}' renamed and restored to '#{new_name}'"
63
+ new_serializer = Serializer::Mbox.new(local_path, new_name)
64
+ new_folder = Account::Folder.new(self, new_name)
65
+ new_folder.create
66
+ new_serializer.force_uid_validity(new_folder.uid_validity)
67
+ Uploader.new(new_folder, new_serializer).run
68
+ else
69
+ Uploader.new(folder, serializer).run
70
+ end
71
+ else
72
+ folder.create
73
+ serializer.force_uid_validity(folder.uid_validity)
74
+ Uploader.new(folder, serializer).run
75
+ end
76
+ end
77
+ end
78
+
54
79
  def disconnect
55
80
  imap.disconnect
56
81
  end
57
82
 
83
+ def reconnect
84
+ disconnect
85
+ @imap = nil
86
+ end
87
+
58
88
  def imap
59
89
  return @imap unless @imap.nil?
60
90
  options = provider_options
@@ -95,6 +125,17 @@ module Imap::Backup
95
125
  (folders || []).map { |f| {name: f.name} }
96
126
  end
97
127
 
128
+ def local_folders
129
+ glob = File.join(local_path, "**", "*.imap")
130
+ base = Pathname.new(local_path)
131
+ Pathname.glob(glob) do |path|
132
+ name = path.relative_path_from(base).to_s[0..-6]
133
+ serializer = Serializer::Mbox.new(local_path, name)
134
+ folder = Account::Folder.new(self, name)
135
+ yield serializer, folder
136
+ end
137
+ end
138
+
98
139
  def provider
99
140
  @provider ||= Email::Provider.for_address(username)
100
141
  end
@@ -16,14 +16,36 @@ module Imap::Backup
16
16
  def initialize(connection, name)
17
17
  @connection = connection
18
18
  @name = name
19
+ @uid_validity = nil
19
20
  end
20
21
 
22
+ # Deprecated: use #name
21
23
  def folder
22
24
  name
23
25
  end
24
26
 
27
+ def exist?
28
+ examine
29
+ true
30
+ rescue Net::IMAP::NoResponseError => e
31
+ false
32
+ end
33
+
34
+ def create
35
+ return if exist?
36
+ imap.create(name)
37
+ end
38
+
39
+ def uid_validity
40
+ @uid_validity ||=
41
+ begin
42
+ examine
43
+ imap.responses["UIDVALIDITY"][-1]
44
+ end
45
+ end
46
+
25
47
  def uids
26
- imap.examine(name)
48
+ examine
27
49
  imap.uid_search(["ALL"]).sort
28
50
  rescue Net::IMAP::NoResponseError
29
51
  Imap::Backup.logger.warn "Folder '#{name}' does not exist"
@@ -31,7 +53,7 @@ module Imap::Backup
31
53
  end
32
54
 
33
55
  def fetch(uid)
34
- imap.examine(name)
56
+ examine
35
57
  fetch_data_items = imap.uid_fetch([uid.to_i], REQUESTED_ATTRIBUTES)
36
58
  return nil if fetch_data_items.nil?
37
59
  fetch_data_item = fetch_data_items[0]
@@ -42,5 +64,23 @@ module Imap::Backup
42
64
  Imap::Backup.logger.warn "Folder '#{name}' does not exist"
43
65
  nil
44
66
  end
67
+
68
+ def append(message)
69
+ body = message.imap_body
70
+ date = message.date.to_time
71
+ response = imap.append(name, body, nil, date)
72
+ extract_uid(response)
73
+ end
74
+
75
+ private
76
+
77
+ def examine
78
+ imap.examine(name)
79
+ end
80
+
81
+ def extract_uid(response)
82
+ @uid_validity, uid = response.data.code.data.split(" ").map(&:to_i)
83
+ uid
84
+ end
45
85
  end
46
86
  end
@@ -0,0 +1,6 @@
1
+ module Imap::Backup
2
+ module Serializer
3
+ DIRECTORY_PERMISSIONS = 0o700
4
+ FILE_PERMISSIONS = 0o600
5
+ end
6
+ end
@@ -1,112 +1,92 @@
1
- require "csv"
2
- require "email/mboxrd/message"
1
+ require "imap/backup/serializer/mbox_store"
3
2
 
4
3
  module Imap::Backup
5
- module Serializer; end
4
+ class Serializer::Mbox
5
+ attr_reader :path
6
+ attr_reader :folder
6
7
 
7
- class Serializer::Mbox < Serializer::Base
8
8
  def initialize(path, folder)
9
- super
10
- create_containing_directory
11
- assert_files
9
+ @path = path
10
+ @folder = folder
12
11
  end
13
12
 
14
- # TODO: cleanup locks, close file handles
15
-
16
- def uids
17
- return @uids if @uids
18
-
19
- @uids = []
20
- return @uids if !exist?
21
-
22
- CSV.foreach(imap_pathname) do |row|
23
- @uids << row[0]
13
+ def set_uid_validity(value)
14
+ existing_uid_validity = store.uid_validity
15
+ case
16
+ when existing_uid_validity.nil?
17
+ store.uid_validity = value
18
+ nil
19
+ when existing_uid_validity == value
20
+ # NOOP
21
+ nil
22
+ else
23
+ digit = nil
24
+ new_name = nil
25
+ loop do
26
+ extra = digit ? ".#{digit}" : ""
27
+ new_name = "#{folder}.#{existing_uid_validity}#{extra}"
28
+ test_store = Serializer::MboxStore.new(path, new_name)
29
+ break if !test_store.exist?
30
+ digit ||= 0
31
+ digit += 1
32
+ end
33
+ store.rename new_name
34
+ @store = nil
35
+ store.uid_validity = value
36
+ new_name
24
37
  end
25
- @uids = @uids.map(&:to_i).sort
26
- @uids
27
38
  end
28
39
 
29
- def save(uid, message)
30
- uid = uid.to_s
31
- if uids.include?(uid)
32
- Imap::Backup.logger.debug(
33
- "[#{folder}] message #{uid} already downloaded - skipping"
34
- )
35
- return
36
- end
37
-
38
- # invalidate cache
39
- @uids = nil
40
-
41
- body = message["RFC822"]
42
- mboxrd_message = Email::Mboxrd::Message.new(body)
43
- mbox = imap = nil
44
- begin
45
- mbox = File.open(mbox_pathname, "ab")
46
- imap = File.open(imap_pathname, "ab")
47
- mbox.write mboxrd_message.to_serialized
48
- imap.write uid + "\n"
49
- rescue => e
50
- message = <<-ERROR.gsub(/^\s*/m, "")
51
- [#{folder}] failed to save message #{uid}:
52
- #{body}. #{e}:
53
- #{e.backtrace.join("\n")}"
54
- ERROR
55
- Imap::Backup.logger.warn message
56
- ensure
57
- mbox.close if mbox
58
- imap.close if imap
59
- end
40
+ def force_uid_validity(value)
41
+ store.uid_validity = value
60
42
  end
61
43
 
62
- private
63
-
64
- def assert_files
65
- mbox = mbox_exist?
66
- imap = imap_exist?
67
- raise ".imap file missing" if mbox && (!imap)
68
- raise ".mbox file missing" if imap && (!mbox)
44
+ def uids
45
+ store.uids
69
46
  end
70
47
 
71
- def create_containing_directory
72
- mbox_relative_path = File.dirname(mbox_relative_pathname)
73
- return if mbox_relative_path == "."
74
- Utils.make_folder(
75
- path, mbox_relative_path, Serializer::DIRECTORY_PERMISSIONS
76
- )
48
+ def load(uid)
49
+ store.load(uid)
77
50
  end
78
51
 
79
- def exist?
80
- mbox_exist? && imap_exist?
52
+ def save(uid, message)
53
+ store.add(uid, message)
81
54
  end
82
55
 
83
- def mbox_exist?
84
- File.exist?(mbox_pathname)
56
+ def rename(new_name)
57
+ @folder = new_name
58
+ store.rename new_name
85
59
  end
86
60
 
87
- def imap_exist?
88
- File.exist?(imap_pathname)
61
+ def update_uid(old, new)
62
+ store.update_uid old, new
89
63
  end
90
64
 
91
- def mbox_relative_pathname
92
- folder + ".mbox"
93
- end
65
+ private
94
66
 
95
- def mbox_pathname
96
- File.join(path, mbox_relative_pathname)
67
+ def store
68
+ @store ||=
69
+ begin
70
+ create_containing_directory
71
+ Serializer::MboxStore.new(path, folder)
72
+ end
97
73
  end
98
74
 
99
- def imap_pathname
100
- filename = folder + ".imap"
101
- File.join(path, filename)
102
- end
75
+ def create_containing_directory
76
+ relative_path = File.dirname(folder)
77
+ containing_directory = File.join(path, relative_path)
78
+ full_path = File.expand_path(containing_directory)
103
79
 
104
- def lock
105
- # lock mbox and imap files
106
- # create both empty if missing
107
- end
80
+ if !File.directory?(full_path)
81
+ Imap::Backup::Utils.make_folder(
82
+ path, relative_path, Serializer::DIRECTORY_PERMISSIONS
83
+ )
84
+ end
108
85
 
109
- def unlock
86
+ if Imap::Backup::Utils.mode(full_path) !=
87
+ Serializer::DIRECTORY_PERMISSIONS
88
+ FileUtils.chmod Serializer::DIRECTORY_PERMISSIONS, full_path
89
+ end
110
90
  end
111
91
  end
112
92
  end