imap-backup 4.0.0 → 4.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -49
  3. data/docs/development.md +53 -0
  4. data/docs/restore.md +17 -0
  5. data/imap-backup.gemspec +4 -7
  6. data/lib/email/mboxrd/message.rb +6 -2
  7. data/lib/email/provider/apple_mail.rb +7 -0
  8. data/lib/email/provider/default.rb +12 -0
  9. data/lib/email/provider/fastmail.rb +7 -0
  10. data/lib/email/provider/gmail.rb +7 -0
  11. data/lib/email/provider.rb +16 -26
  12. data/lib/imap/backup/account/connection.rb +19 -29
  13. data/lib/imap/backup/account/folder.rb +9 -10
  14. data/lib/imap/backup/cli/helpers.rb +14 -1
  15. data/lib/imap/backup/cli/local.rb +22 -28
  16. data/lib/imap/backup/cli/setup.rb +1 -1
  17. data/lib/imap/backup/cli/utils.rb +98 -0
  18. data/lib/imap/backup/cli.rb +5 -1
  19. data/lib/imap/backup/client/apple_mail.rb +11 -0
  20. data/lib/imap/backup/client/default.rb +51 -0
  21. data/lib/imap/backup/configuration/account.rb +3 -1
  22. data/lib/imap/backup/configuration/connection_tester.rb +1 -1
  23. data/lib/imap/backup/configuration/store.rb +10 -5
  24. data/lib/imap/backup/downloader.rb +3 -4
  25. data/lib/imap/backup/serializer/mbox.rb +5 -0
  26. data/lib/imap/backup/serializer/mbox_store.rb +8 -8
  27. data/lib/imap/backup/thunderbird/mailbox_exporter.rb +58 -0
  28. data/lib/imap/backup/version.rb +1 -1
  29. data/lib/imap/backup.rb +1 -0
  30. data/lib/thunderbird/install.rb +16 -0
  31. data/lib/thunderbird/local_folder.rb +65 -0
  32. data/lib/thunderbird/profile.rb +30 -0
  33. data/lib/thunderbird/profiles.rb +71 -0
  34. data/lib/thunderbird/subdirectory.rb +96 -0
  35. data/lib/thunderbird/subdirectory_placeholder.rb +21 -0
  36. data/lib/thunderbird.rb +14 -0
  37. data/spec/features/restore_spec.rb +1 -1
  38. data/spec/features/support/email_server.rb +2 -2
  39. data/spec/spec_helper.rb +1 -0
  40. data/spec/unit/email/provider/apple_mail_spec.rb +7 -0
  41. data/spec/unit/email/provider/default_spec.rb +17 -0
  42. data/spec/unit/email/provider/fastmail_spec.rb +7 -0
  43. data/spec/unit/email/provider/gmail_spec.rb +7 -0
  44. data/spec/unit/email/provider_spec.rb +12 -25
  45. data/spec/unit/imap/backup/account/connection_spec.rb +26 -51
  46. data/spec/unit/imap/backup/account/folder_spec.rb +22 -22
  47. data/spec/unit/imap/backup/cli/local_spec.rb +70 -0
  48. data/spec/unit/imap/backup/cli/utils_spec.rb +50 -0
  49. data/spec/unit/imap/backup/client/default_spec.rb +22 -0
  50. data/spec/unit/imap/backup/configuration/connection_tester_spec.rb +3 -3
  51. data/spec/unit/imap/backup/configuration/store_spec.rb +25 -12
  52. data/spec/unit/imap/backup/downloader_spec.rb +1 -2
  53. metadata +71 -28
  54. data/docs/docker-imap.md +0 -18
@@ -0,0 +1,98 @@
1
+ require "imap/backup/thunderbird/mailbox_exporter"
2
+
3
+ module Imap::Backup
4
+ class CLI::Utils < Thor
5
+ include Thor::Actions
6
+ include CLI::Helpers
7
+
8
+ FAKE_EMAIL = "fake@email.com".freeze
9
+
10
+ desc "ignore-history EMAIL", "Skip downloading emails up to today for all configured folders"
11
+ def ignore_history(email)
12
+ connection = connection(email)
13
+
14
+ connection.local_folders.each do |serializer, folder|
15
+ next if !folder.exist?
16
+
17
+ do_ignore_folder_history(folder, serializer)
18
+ end
19
+ end
20
+
21
+ desc(
22
+ "export-to-thunderbird EMAIL [OPTIONS]",
23
+ <<~DOC
24
+ [Experimental] Copy backed up emails to Thunderbird.
25
+ A folder called 'imap-backup/EMAIL' is created under 'Local Folders'.
26
+ DOC
27
+ )
28
+ method_option(
29
+ "force",
30
+ type: :boolean,
31
+ banner: "overwrite existing mailboxes",
32
+ aliases: ["-f"]
33
+ )
34
+ method_option(
35
+ "profile",
36
+ type: :string,
37
+ banner: "the name of the Thunderbird profile to copy emails to",
38
+ aliases: ["-p"]
39
+ )
40
+ def export_to_thunderbird(email)
41
+ opts = symbolized(options)
42
+ force = opts.key?(:force) ? opts[:force] : false
43
+ profile_name = opts[:profile]
44
+
45
+ connection = connection(email)
46
+ profile = thunderbird_profile(profile_name)
47
+
48
+ if !profile
49
+ if profile_name
50
+ raise "Thunderbird profile '#{profile_name}' not found"
51
+ else
52
+ raise "Default Thunderbird profile not found"
53
+ end
54
+ end
55
+
56
+ connection.local_folders.each do |serializer, _folder|
57
+ Thunderbird::MailboxExporter.new(
58
+ email, serializer, profile, force: force
59
+ ).run
60
+ end
61
+ end
62
+
63
+ no_commands do
64
+ def do_ignore_folder_history(folder, serializer)
65
+ uids = folder.uids - serializer.uids
66
+ Imap::Backup.logger.info "Folder '#{folder.name}' - #{uids.length} messages"
67
+
68
+ serializer.apply_uid_validity(folder.uid_validity)
69
+
70
+ uids.each do |uid|
71
+ message = <<~MESSAGE
72
+ From: #{FAKE_EMAIL}
73
+ Subject: Message #{uid} not backed up
74
+ Skipped #{uid}
75
+ MESSAGE
76
+
77
+ serializer.save(uid, message)
78
+ end
79
+ end
80
+
81
+ def thunderbird_profile(name = nil)
82
+ profiles = Thunderbird::Profiles.new
83
+ if name
84
+ profiles.profile(name)
85
+ else
86
+ if profiles.installs.count > 1
87
+ raise <<~MESSAGE
88
+ Thunderbird has multiple installs, so no default profile exists.
89
+ Please supply a profile name
90
+ MESSAGE
91
+ end
92
+
93
+ profiles.installs[0].default
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -13,6 +13,7 @@ module Imap::Backup
13
13
  autoload :Restore, "imap/backup/cli/restore"
14
14
  autoload :Setup, "imap/backup/cli/setup"
15
15
  autoload :Status, "imap/backup/cli/status"
16
+ autoload :Utils, "imap/backup/cli/utils"
16
17
 
17
18
  include Helpers
18
19
 
@@ -73,7 +74,7 @@ module Imap::Backup
73
74
  Configure email accounts to back up.
74
75
  DESC
75
76
  def setup
76
- Setup.new().run
77
+ Setup.new.run
77
78
  end
78
79
 
79
80
  desc "status", "Show backup status"
@@ -87,5 +88,8 @@ module Imap::Backup
87
88
 
88
89
  desc "local SUBCOMMAND [OPTIONS]", "View local info"
89
90
  subcommand "local", Local
91
+
92
+ desc "utils SUBCOMMAND [OPTIONS]", "Various utilities"
93
+ subcommand "utils", Utils
90
94
  end
91
95
  end
@@ -0,0 +1,11 @@
1
+ require "imap/backup/client/default"
2
+
3
+ module Imap::Backup
4
+ class Client::AppleMail < Client::Default
5
+ # With Apple Mails's IMAP, passing "/" to list
6
+ # results in an empty list
7
+ def provider_root
8
+ ""
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,51 @@
1
+ require "forwardable"
2
+ require "net/imap"
3
+
4
+ module Imap::Backup
5
+ module Client; end
6
+
7
+ class Client::Default
8
+ extend Forwardable
9
+ def_delegators :imap, *%i(
10
+ append authenticate create disconnect examine
11
+ login responses uid_fetch uid_search
12
+ )
13
+
14
+ attr_reader :args
15
+
16
+ def initialize(*args)
17
+ @args = args
18
+ end
19
+
20
+ def list
21
+ root = provider_root
22
+ mailbox_lists = imap.list(root, "*")
23
+
24
+ return [] if mailbox_lists.nil?
25
+
26
+ mailbox_lists.map { |ml| extract_name(ml) }
27
+ end
28
+
29
+ private
30
+
31
+ def imap
32
+ @imap ||= Net::IMAP.new(*args)
33
+ end
34
+
35
+ def extract_name(mailbox_list)
36
+ utf7_encoded = mailbox_list.name
37
+ Net::IMAP.decode_utf7(utf7_encoded)
38
+ end
39
+
40
+ # 6.3.8. LIST Command
41
+ # An empty ("" string) mailbox name argument is a special request to
42
+ # return the hierarchy delimiter and the root name of the name given
43
+ # in the reference.
44
+ def provider_root
45
+ @provider_root ||= begin
46
+ root_info = imap.list("", "")[0]
47
+ root_info.name
48
+ end
49
+ end
50
+ end
51
+ end
@@ -147,10 +147,12 @@ module Imap::Backup
147
147
 
148
148
  def default_server(username)
149
149
  provider = Email::Provider.for_address(username)
150
- if provider.provider == :default
150
+
151
+ if provider.is_a?(Email::Provider::Default)
151
152
  Kernel.puts "Can't decide provider for email address '#{username}'"
152
153
  return nil
153
154
  end
155
+
154
156
  provider.host
155
157
  end
156
158
  end
@@ -3,7 +3,7 @@ module Imap::Backup
3
3
 
4
4
  module Configuration::ConnectionTester
5
5
  def self.test(account)
6
- Account::Connection.new(account).imap
6
+ Account::Connection.new(account).client
7
7
  "Connection successful"
8
8
  rescue Net::IMAP::NoResponseError
9
9
  "No response"
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "os"
2
3
 
3
4
  module Imap::Backup
4
5
  module Configuration; end
@@ -25,11 +26,12 @@ module Imap::Backup
25
26
  end
26
27
 
27
28
  def save
28
- mkdir_private path
29
+ FileUtils.mkdir(path) if !File.directory?(path)
30
+ make_private(path) if !windows?
29
31
  remove_modified_flags
30
32
  remove_deleted_accounts
31
33
  File.open(pathname, "w") { |f| f.write(JSON.pretty_generate(data)) }
32
- FileUtils.chmod 0o600, pathname
34
+ FileUtils.chmod(0o600, pathname) if !windows?
33
35
  end
34
36
 
35
37
  def accounts
@@ -54,7 +56,7 @@ module Imap::Backup
54
56
  @data ||=
55
57
  begin
56
58
  if File.exist?(pathname)
57
- Utils.check_permissions pathname, 0o600
59
+ Utils.check_permissions(pathname, 0o600) if !windows?
58
60
  contents = File.read(pathname)
59
61
  data = JSON.parse(contents, symbolize_names: true)
60
62
  else
@@ -73,9 +75,12 @@ module Imap::Backup
73
75
  accounts.reject! { |a| a[:delete] }
74
76
  end
75
77
 
76
- def mkdir_private(path)
77
- FileUtils.mkdir(path) if !File.directory?(path)
78
+ def make_private(path)
78
79
  FileUtils.chmod(0o700, path) if Utils.mode(path) != 0o700
79
80
  end
81
+
82
+ def windows?
83
+ OS.windows?
84
+ end
80
85
  end
81
86
  end
@@ -13,16 +13,15 @@ module Imap::Backup
13
13
  count = uids.count
14
14
  Imap::Backup.logger.debug "[#{folder.name}] #{count} new messages"
15
15
  uids.each.with_index do |uid, i|
16
- message = folder.fetch(uid)
16
+ body = folder.fetch(uid)
17
17
  log_prefix = "[#{folder.name}] uid: #{uid} (#{i + 1}/#{count}) -"
18
- if message.nil?
18
+ if body.nil?
19
19
  Imap::Backup.logger.debug("#{log_prefix} not available - skipped")
20
20
  next
21
21
  end
22
22
  Imap::Backup.logger.debug(
23
- "#{log_prefix} #{message['RFC822'].size} bytes"
23
+ "#{log_prefix} #{body.size} bytes"
24
24
  )
25
- body = message["RFC822"]
26
25
  serializer.save(uid, body)
27
26
  end
28
27
  end
@@ -1,7 +1,12 @@
1
+ require "forwardable"
2
+
1
3
  require "imap/backup/serializer/mbox_store"
2
4
 
3
5
  module Imap::Backup
4
6
  class Serializer::Mbox
7
+ extend Forwardable
8
+ def_delegators :store, :mbox_pathname
9
+
5
10
  attr_reader :path
6
11
  attr_reader :folder
7
12
 
@@ -121,6 +121,14 @@ module Imap::Backup
121
121
  @folder = new_name
122
122
  end
123
123
 
124
+ def mbox_pathname
125
+ absolute_path("#{folder}.mbox")
126
+ end
127
+
128
+ def imap_pathname
129
+ absolute_path("#{folder}.imap")
130
+ end
131
+
124
132
  private
125
133
 
126
134
  def do_load
@@ -205,13 +213,5 @@ module Imap::Backup
205
213
  def absolute_path(relative_path)
206
214
  File.join(path, relative_path)
207
215
  end
208
-
209
- def mbox_pathname
210
- absolute_path("#{folder}.mbox")
211
- end
212
-
213
- def imap_pathname
214
- absolute_path("#{folder}.imap")
215
- end
216
216
  end
217
217
  end
@@ -0,0 +1,58 @@
1
+ require "thunderbird/local_folder"
2
+ require "thunderbird/profiles"
3
+
4
+ module Imap::Backup
5
+ class Thunderbird::MailboxExporter
6
+ EXPORT_PREFIX = "imap-backup".freeze
7
+
8
+ attr_reader :email
9
+ attr_reader :serializer
10
+ attr_reader :profile
11
+ attr_reader :force
12
+
13
+ def initialize(email, serializer, profile, force: false)
14
+ @email = email
15
+ @serializer = serializer
16
+ @profile = profile
17
+ @force = force
18
+ end
19
+
20
+ def run
21
+ local_folder_ok = local_folder.set_up
22
+ return if !local_folder_ok
23
+
24
+ if local_folder.msf_exists?
25
+ if force
26
+ Kernel.puts "Deleting '#{local_folder.msf_path}' as --force option was supplied"
27
+ File.unlink local_folder.msf_path
28
+ else
29
+ Kernel.puts "Skipping export of '#{serializer.folder}' as '#{local_folder.msf_path}' exists"
30
+ return false
31
+ end
32
+ end
33
+
34
+ if local_folder.exists?
35
+ if force
36
+ Kernel.puts "Overwriting '#{local_folder.path}' as --force option was supplied"
37
+ else
38
+ Kernel.puts "Skipping export of '#{serializer.folder}' as '#{local_folder.path}' exists"
39
+ return false
40
+ end
41
+ end
42
+
43
+ FileUtils.cp serializer.mbox_pathname, local_folder.full_path
44
+
45
+ true
46
+ end
47
+
48
+ private
49
+
50
+ def local_folder
51
+ @local_folder ||= begin
52
+ top_level_folders = [EXPORT_PREFIX, email]
53
+ prefixed_folder_path = File.join(top_level_folders, serializer.folder)
54
+ Thunderbird::LocalFolder.new(profile, prefixed_folder_path)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -3,7 +3,7 @@ module Imap; end
3
3
  module Imap::Backup
4
4
  MAJOR = 4
5
5
  MINOR = 0
6
- REVISION = 0
6
+ REVISION = 4
7
7
  PRE = nil
8
8
  VERSION = [MAJOR, MINOR, REVISION, PRE].compact.map(&:to_s).join(".")
9
9
  end
data/lib/imap/backup.rb CHANGED
@@ -29,6 +29,7 @@ module Imap::Backup
29
29
 
30
30
  def initialize
31
31
  @logger = ::Logger.new($stdout)
32
+ $stdout.sync = true
32
33
  end
33
34
  end
34
35
 
@@ -0,0 +1,16 @@
1
+ require "thunderbird/profiles"
2
+
3
+ class Thunderbird::Install
4
+ attr_reader :title
5
+ attr_reader :entries
6
+
7
+ # entries are lines from profile.ini
8
+ def initialize(title, entries)
9
+ @title = title
10
+ @entries = entries
11
+ end
12
+
13
+ def default
14
+ Thunderbird::Profiles.new.profile_for_path(entries[:Default])
15
+ end
16
+ end
@@ -0,0 +1,65 @@
1
+ require "thunderbird/profile"
2
+ require "thunderbird/subdirectory"
3
+
4
+ # A local folder is a file containing emails
5
+ class Thunderbird::LocalFolder
6
+ attr_reader :path
7
+ attr_reader :profile
8
+
9
+ def initialize(profile, path)
10
+ @profile = profile
11
+ @path = path
12
+ end
13
+
14
+ def set_up
15
+ return if path_elements.empty?
16
+
17
+ return true if !in_subdirectory?
18
+
19
+ subdirectory.set_up
20
+ end
21
+
22
+ def full_path
23
+ if in_subdirectory?
24
+ File.join(subdirectory.full_path, folder_name)
25
+ else
26
+ folder_name
27
+ end
28
+ end
29
+
30
+ def exists?
31
+ File.exist?(full_path)
32
+ end
33
+
34
+ def msf_path
35
+ "#{path}.msf"
36
+ end
37
+
38
+ def msf_exists?
39
+ File.exist?(msf_path)
40
+ end
41
+
42
+ private
43
+
44
+ def in_subdirectory?
45
+ path_elements.count > 1
46
+ end
47
+
48
+ def subdirectory
49
+ return nil if !in_subdirectory?
50
+
51
+ Thunderbird::Subdirectory.new(profile, subdirectory_path)
52
+ end
53
+
54
+ def path_elements
55
+ path.split(File::SEPARATOR)
56
+ end
57
+
58
+ def subdirectory_path
59
+ File.join(path_elements[0..-2])
60
+ end
61
+
62
+ def folder_name
63
+ path_elements[-1]
64
+ end
65
+ end
@@ -0,0 +1,30 @@
1
+ require "thunderbird"
2
+
3
+ class Thunderbird::Profile
4
+ attr_reader :title
5
+ attr_reader :entries
6
+
7
+ # entries are lines from profile.ini
8
+ def initialize(title, entries)
9
+ @title = title
10
+ @entries = entries
11
+ end
12
+
13
+ def root
14
+ if relative?
15
+ File.join(Thunderbird.new.data_path, entries[:Path])
16
+ else
17
+ entries[:Path]
18
+ end
19
+ end
20
+
21
+ def local_folders_path
22
+ File.join(root, "Mail", "Local Folders")
23
+ end
24
+
25
+ private
26
+
27
+ def relative?
28
+ entries[:IsRelative] == "1"
29
+ end
30
+ end
@@ -0,0 +1,71 @@
1
+ require "thunderbird"
2
+ require "thunderbird/install"
3
+ require "thunderbird/profile"
4
+
5
+ # http://kb.mozillazine.org/Profiles.ini_file
6
+ class Thunderbird::Profiles
7
+ def profile_for_path(path)
8
+ title, entries = blocks.find { |_name, entries| entries[:Path] == path }
9
+
10
+ Thunderbird::Profile.new(title, entries) if title
11
+ end
12
+
13
+ def profile(name)
14
+ title, entries = blocks.find { |_name, entries| entries[:Name] == name }
15
+
16
+ Thunderbird::Profile.new(title, entries) if title
17
+ end
18
+
19
+ def installs
20
+ @installs ||= begin
21
+ pairs = blocks.filter { |name, _entries| name.start_with?("Install") }
22
+ pairs.map { |title, entries| Thunderbird::Install.new(title, entries) }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # Parse profiles.ini.
29
+ # Blocks start with a title, e.g. '[Abc]'
30
+ # and are followed by a number of lines
31
+ def blocks
32
+ @blocks ||= begin
33
+ blocks = {}
34
+ File.open(profiles_ini_path, "rb") do |f|
35
+ title = nil
36
+ entries = nil
37
+
38
+ loop do
39
+ line = f.gets
40
+ if !line
41
+ blocks[title] = entries if title
42
+ break
43
+ end
44
+
45
+ line.chomp!
46
+
47
+ # Is this line the start of a new block
48
+ match = line.match(/\A\[([A-Za-z0-9]+)\]\z/)
49
+ if match
50
+ # Store what we got before this title as a new block
51
+ blocks[title] = entries if title
52
+
53
+ # Start a new block
54
+ title = match[1]
55
+ entries = {}
56
+ elsif line != ""
57
+ # Collect entries until we get to the next title
58
+ key, value = line.split("=")
59
+ entries[key.to_sym] = value
60
+ end
61
+ end
62
+ end
63
+
64
+ blocks
65
+ end
66
+ end
67
+
68
+ def profiles_ini_path
69
+ File.join(Thunderbird.new.data_path, "profiles.ini")
70
+ end
71
+ end
@@ -0,0 +1,96 @@
1
+ require "thunderbird/subdirectory_placeholder"
2
+
3
+ class Thunderbird::Subdirectory
4
+ # `path` is the UI path, it doesn't have the '.sbd' extensions
5
+ # that are present in the real, file system path
6
+ attr_reader :path
7
+ attr_reader :profile
8
+
9
+ def initialize(profile, path)
10
+ @profile = profile
11
+ @path = path
12
+ end
13
+
14
+ def set_up
15
+ raise "Cannot create a subdirectory without a path" if !sub_directory?
16
+
17
+ if sub_sub_directory?
18
+ parent_ok = parent.set_up
19
+ return false if !parent_ok
20
+ end
21
+
22
+ ok = check
23
+ return false if !ok
24
+
25
+ FileUtils.mkdir_p full_path
26
+ placeholder.touch
27
+
28
+ true
29
+ end
30
+
31
+ # subdirectory relative path is 'Foo.sbd/Bar.sbd/Baz.sbd'
32
+ def full_path
33
+ relative_path = File.join(subdirectories)
34
+ File.join(profile.local_folders_path, relative_path)
35
+ end
36
+
37
+ private
38
+
39
+ def sub_directory?
40
+ path_elements.any?
41
+ end
42
+
43
+ def sub_sub_directory?
44
+ path_elements.count > 1
45
+ end
46
+
47
+ def parent
48
+ return nil if !sub_sub_directory?
49
+
50
+ self.class.new(profile, File.join(path_elements[0..-2]))
51
+ end
52
+
53
+ # placeholder relative path is 'Foo.sbd/Bar.sbd/Baz'
54
+ def placeholder
55
+ @placeholder = begin
56
+ relative_path = File.join(subdirectories[0..-2], path_elements[-1])
57
+ path = File.join(profile.local_folders_path, relative_path)
58
+ Thunderbird::SubdirectoryPlaceholder.new(path)
59
+ end
60
+ end
61
+
62
+ def path_elements
63
+ path.split(File::SEPARATOR)
64
+ end
65
+
66
+ def exists?
67
+ File.exist?(full_path)
68
+ end
69
+
70
+ def directory?
71
+ File.directory?(full_path)
72
+ end
73
+
74
+ def subdirectories
75
+ path_elements.map { |p| "#{p}.sbd" }
76
+ end
77
+
78
+ def check
79
+ case
80
+ when placeholder.exists? && !exists?
81
+ Kernel.puts "Can't set up folder '#{folder_path}': '#{placeholder.path}' exists, but '#{full_path}' is missing"
82
+ false
83
+ when exists? && !placeholder.exists?
84
+ Kernel.puts "Can't set up folder '#{folder_path}': '#{full_path}' exists, but '#{placeholder.path}' is missing"
85
+ false
86
+ when placeholder.exists? && !placeholder.regular?
87
+ Kernel.puts "Can't set up folder '#{folder_path}': '#{placeholder.path}' exists, but it is not a regular file"
88
+ false
89
+ when exists? && !directory?
90
+ Kernel.puts "Can't set up folder '#{folder_path}': '#{full_path}' exists, but it is not a directory"
91
+ false
92
+ else
93
+ true
94
+ end
95
+ end
96
+ end