mailchain_connector_imap 0.1.4

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.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/imap'
4
+ require 'pstore'
5
+ require_relative '../connection_configuration/imap'
6
+
7
+ # Handles the Imap configuration and connection
8
+ class ConnectionImap
9
+ STORE_PATH = "#{ENV['HOME']}/.mailchain_connector/imap/"
10
+ STORE_FILE = File.join(STORE_PATH, 'mailchain_connector_imap.pstore')
11
+
12
+ # reads the config file and sets `@config`
13
+ def initialize(config, config_file)
14
+ get_or_create_pstore
15
+ @config = config
16
+ @config_file = config_file
17
+ end
18
+
19
+ # Check for pstore, and create if not exist
20
+ def get_or_create_pstore
21
+ (FileUtils.mkdir_p(STORE_PATH) unless File.exist?(STORE_FILE))
22
+ @pstore = PStore.new(STORE_FILE, true)
23
+ end
24
+
25
+ # Records in pstore with the MD5 hexdigest of message_id as key, with prefix 'append_' and value as true.
26
+ # Stored as md5 hash to obfuscate message ids.
27
+ def store_msg_appended(message_id)
28
+ message_id_hash = Digest::MD5.hexdigest(message_id)
29
+ @pstore.transaction { @pstore['append_' + message_id_hash] = true }
30
+ end
31
+
32
+ # Checks if MD5 hexdigest of message_id as key, with prefix 'append_' returns a true value from the database.
33
+ # Stored as md5 hash to obfuscate message ids.
34
+ # Returns true or false
35
+ def msg_appended?(message_id)
36
+ message_id_hash = Digest::MD5.hexdigest(message_id)
37
+ @pstore.transaction { @pstore['append_' + message_id_hash] } == true
38
+ end
39
+
40
+ # # Run the IMAP configuration
41
+ def configuration_wizard
42
+ connection_configuration = ConnectionConfigurationImap.new(@config)
43
+ result = connection_configuration.configuration_wizard
44
+ if result['save']
45
+ result['config']['imap'].delete('password')
46
+ new_config_json = JSON.pretty_generate(result['config'])
47
+ File.write(@config_file, new_config_json)
48
+ end
49
+ end
50
+
51
+ # Connect to the IMAP server, attempting 'LOGIN' then 'PLAIN'
52
+ def connect
53
+ check_password
54
+ @connection ||= Net::IMAP.new(@config['imap']['server'], @config['imap']['port'], @config['imap']['ssl'])
55
+ res = true
56
+ unless connected_and_authenticated?
57
+ begin
58
+ @connection.authenticate('LOGIN', @config['imap']['username'], @config['imap']['password'])
59
+ rescue StandardError
60
+ begin
61
+ @connection.authenticate('PLAIN', @config['imap']['username'], @config['imap']['password'])
62
+ rescue StandardError => e
63
+ puts "IMAP failed to connect: #{e}"
64
+ res = false
65
+ end
66
+ end
67
+ end
68
+ res
69
+ end
70
+
71
+ def check_password
72
+ unless @config['imap']['password']
73
+ # Get imap password
74
+ prompt = TTY::Prompt.new
75
+ @config['imap']['password'] = prompt.mask(
76
+ 'Enter your imap password', required: true
77
+ )
78
+ end
79
+ end
80
+
81
+ # Sets the connection delimiter
82
+ def delimiter
83
+ if @delimiter.nil?
84
+ folders = list_folders
85
+ @delimiter = folders[0][:delim]
86
+ end
87
+ @delimiter
88
+ end
89
+
90
+ # Disconnects from the server
91
+ def disconnect
92
+ @connection.disconnect unless @connection.nil? || @connection.disconnected?
93
+ @connection = nil
94
+ end
95
+
96
+ # Configures the IMAP server settings then tests the connection
97
+ def configure_and_connect
98
+ if !configuration_wizard # TODO: - wire up to connection configuration
99
+ exit
100
+ else
101
+ test_connection
102
+ end
103
+ end
104
+
105
+ # Tests the connection to the IMAP server
106
+ def test_connection
107
+ puts 'Testing IMAP connection...'
108
+ puts 'IMAP connection was successful' if connect
109
+ disconnect unless @connection.disconnected?
110
+ true
111
+ end
112
+
113
+ # Returns the target mailbox for the message according to the folder structre and Inbox preferences
114
+ def get_mailbox(protocol, address, network)
115
+ p_address = case protocol
116
+ when 'ethereum'
117
+ "0x#{address}"
118
+ else
119
+ address
120
+ end
121
+
122
+ if @config['mailchain']['mainnet_to_inbox'] && network.downcase == 'mainnet'
123
+ 'Inbox'
124
+ else
125
+ case @config['mailchain']['folders']
126
+ when 'by_address'
127
+ # 'Address>Protocol>Network'
128
+ "Inbox#{delimiter}#{p_address}#{delimiter}#{protocol}#{delimiter}#{network}"
129
+ when 'by_network'
130
+ # 'Protocol>Network>Address'
131
+ "Inbox#{delimiter}#{protocol}#{delimiter}#{network}#{delimiter}#{p_address}"
132
+ end
133
+ end
134
+ end
135
+
136
+ # Create the folder path for the mailbox according to chosen folder format
137
+ def create_mailbox_path(target_mailbox)
138
+ return if @connection.list('', target_mailbox)
139
+
140
+ folders = target_mailbox.split(delimiter)
141
+ mbox = []
142
+ (0...folders.length).each_with_index do |_folder, index|
143
+ mbox.push(folders[index])
144
+ mbox_as_str = mbox.join(delimiter)
145
+ @connection.create(mbox_as_str) unless @connection.list('', mbox_as_str)
146
+ end
147
+ end
148
+
149
+ # Appends message to mailbox
150
+ # `date_time`: Time
151
+ # Connects and disconnects at the beginning and end of the method
152
+ # if the connection is not defined/ connected already
153
+ def append_message(protocol, network, address, message, message_id, flags = nil, date_time = nil)
154
+ unless msg_appended?(message_id)
155
+ connect unless connected_and_authenticated?
156
+
157
+ target_mailbox = get_mailbox(protocol, address, network)
158
+ create_mailbox_path(target_mailbox)
159
+ @connection.examine(target_mailbox)
160
+
161
+ if @connection.search(['HEADER', 'MESSAGE-ID', message.message_id]).empty?
162
+ @connection.append(target_mailbox, message.to_s, flags, date_time)
163
+ end
164
+ store_msg_appended(message_id)
165
+ end
166
+ end
167
+
168
+ # Lists folders
169
+ def list_folders
170
+ connect
171
+ @connection.list('', '*')
172
+ end
173
+
174
+ # Attempts to list mailboxes (folders). If length > 0, then wemust be authenticated
175
+ def connected_and_authenticated?
176
+ !@connection.disconnected? && !@connection.list('', '*').empty?
177
+ rescue StandardError => e
178
+ false
179
+ end
180
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../api/mailchain'
4
+ require_relative '../connection/mailchain'
5
+ require 'mail'
6
+ # Handles the Mailchain API configuration and connection
7
+ class ConnectionMailchain
8
+ # Initialize configs
9
+ def initialize(config, config_file)
10
+ @config = config
11
+ @config_file = config_file
12
+ @api = MailchainApi.new(@config['mailchain'])
13
+ end
14
+
15
+ # Configures the Mailchain API settings then tests the connection
16
+ def configure_and_connect
17
+ if !configuration_wizard # TODO: - wire up to connection configuration
18
+ exit
19
+ else
20
+ test_connection
21
+ end
22
+ end
23
+
24
+ # # Run the Mailchain API configuration
25
+ def configuration_wizard
26
+ connection_configuration = ConnectionConfigurationMailchain.new(@config)
27
+ result = connection_configuration.configuration_wizard
28
+ if result['save']
29
+ result['config']['imap'].delete('password')
30
+ new_config_json = JSON.pretty_generate(result['config'])
31
+ File.write(@config_file, new_config_json)
32
+ end
33
+ end
34
+
35
+ # Tests the connection to the Mailchain API
36
+ def test_connection(silent = false)
37
+ puts 'Testing API connection...' unless silent
38
+ result = true
39
+ begin
40
+ res = @api.version
41
+ res[:status_code] != 200
42
+ puts "Connection was successful (API version: #{res[:body]['version']})" unless silent
43
+ rescue StandardError => e
44
+ puts "Mailchain API failed to connect with the following error: #{e}"
45
+ puts 'Check the Mailchain client is running and configured correctly'
46
+ result = false
47
+ end
48
+ result
49
+ end
50
+
51
+ # Converts mailchain message to regular email
52
+ def convert_message(message)
53
+ footer = 'Delivered by Mailchain IMAP Connector'
54
+ c_type = get_content_type(message['headers']['content-type'])
55
+
56
+ mail = Mail.new do
57
+ from message['headers']['from']
58
+ to message['headers']['to']
59
+ date message['headers']['date']
60
+ message_id message['headers']['message-id']
61
+ subject message['subject']
62
+ if c_type == 'html'
63
+ html_part do
64
+ content_type message['headers']['content-type']
65
+ body "#{message['body']} <br/><br/>#{footer}"
66
+ end
67
+ end
68
+ if c_type == 'plain'
69
+ text_part do
70
+ content_type message['headers']['content-type']
71
+ body "#{message['body']} \r\n#{footer}"
72
+ end
73
+ end
74
+ end
75
+ mail.header['X-Mailchain-Block-Id'] = message['block-id']
76
+ mail.header['X-Mailchain-Block-Id-Encoding'] = message['block-id-encoding']
77
+ mail.header['X-Mailchain-Transaction-Hash'] = message['transaction-hash']
78
+ mail.header['X-Mailchain-Transaction-Hash-Encoding'] = message['transaction-hash-encoding']
79
+ mail
80
+ end
81
+
82
+ # Returns `text` or `html`
83
+ def get_content_type(content_type)
84
+ case content_type
85
+ when '"text/html; charset=\"UTF-8\""'
86
+ 'html'
87
+ when '"text/plain; charset=\"UTF-8\""'
88
+ 'plain'
89
+ else
90
+ 'plain'
91
+ end
92
+ end
93
+
94
+ # Returns addresses formatted by_network
95
+ # e.g. [{
96
+ # "protocol" => "ethereum",
97
+ # "network" => "kovan",
98
+ # "addresses"=> ["1234567890...", "d5ab4ce..."]
99
+ # }]
100
+ def addresses_by_network
101
+ protocol_networks.map do |obj|
102
+ {
103
+ 'protocol' => obj['protocol'],
104
+ 'network' => obj['network'],
105
+ 'addresses' => @api.addresses(obj['protocol'], obj['network'])[:body]['addresses']
106
+ }
107
+ end
108
+ end
109
+
110
+ # Returns messages formatted by_network
111
+ # e.g. [ address, res['messages'] ]
112
+ def messages_by_network(item)
113
+ protocol = item['protocol']
114
+ network = item['network']
115
+ addresses = item['addresses']
116
+ messages = []
117
+ addresses.each do |address|
118
+ res = get_messages(address, protocol, network)
119
+ messages << [address, res['messages']] unless res['messages'].nil?
120
+ end
121
+ messages
122
+ end
123
+
124
+ # Gets messages from api and returns `body` {"messages" => [...]}
125
+ def get_messages(addr, protocol, network)
126
+ address = "0x#{addr}"
127
+ @api.messages(address, protocol, network)[:body]
128
+ end
129
+
130
+ # Convert and call the append_message for each valid message
131
+ def convert_messages(messages)
132
+ cmgs = []
133
+ messages.each do |msg|
134
+ next unless msg['status'] == 'ok'
135
+
136
+ cm = convert_message(msg)
137
+ cmgs << {
138
+ 'message' => cm,
139
+ 'message_id' => msg['headers']['message-id'],
140
+ 'message_date' => cm.date.to_time
141
+ }
142
+ end
143
+ cmgs
144
+ end
145
+
146
+ # Returns array of each network with parent protocol
147
+ # e.g. [{'protocol' => 'ethereum', 'network' => 'ropsten'},...]
148
+ def protocol_networks
149
+ output = []
150
+ @api.protocols[:body]['protocols'].each do |proto|
151
+ output << proto['networks'].map do |n|
152
+ { 'protocol' => proto['name'], 'network' => n['name'] }
153
+ end
154
+ end
155
+ output.flatten
156
+ rescue StandardError => e
157
+ puts "Error: #{e}"
158
+ end
159
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConnectionConfigurationImap
4
+ attr_reader :config
5
+ attr_reader :print_settings
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ # Run the IMAP configuration wizard
11
+ # Returns hash or either: { "save" => true, "config" => "{ #config }" }
12
+ # - or -
13
+ # { "save" => false }
14
+ def configuration_wizard
15
+ @prompt = TTY::Prompt.new
16
+
17
+ prompt_server
18
+ prompt_username
19
+ prompt_port
20
+ prompt_ssl
21
+
22
+ result = prompt_confirm_save_settings
23
+ @prompt = nil
24
+ result
25
+ end
26
+
27
+ # Prints the IMAP server settings as output in a nice format
28
+ def print_settings
29
+ puts "IMAP Settings:\n" \
30
+ "--------------\n" \
31
+ "Server:\t\t#{@config['imap']['server']}\n" \
32
+ "Port:\t\t#{@config['imap']['port']}\n" \
33
+ "SSL:\t\t#{@config['imap']['ssl']}\n" \
34
+ "Username:\t#{@config['imap']['username']}"
35
+ end
36
+
37
+ # Get imap server config
38
+ def prompt_server
39
+ @config['imap']['server'] = @prompt.ask(
40
+ 'Enter your imap server (e.g. imap.example.com)',
41
+ default: @config['imap']['server']
42
+ )
43
+ end
44
+
45
+ # Get imap username
46
+ def prompt_username
47
+ @config['imap']['username'] = @prompt.ask(
48
+ 'Enter your imap username/ email address (e.g. tim@example.com)',
49
+ default: @config['imap']['username']
50
+ )
51
+ end
52
+
53
+ # Get imap port
54
+ def prompt_port
55
+ @config['imap']['port'] = @config['imap']['port'] || '993'
56
+ @config['imap']['port'] = @prompt.ask(
57
+ 'Enter the imap port to connect to (e.g. IMAP = 143; IMAP SSL = 993)',
58
+ default: @config['imap']['port']
59
+ )
60
+ end
61
+
62
+ # Get imap ssl status
63
+ def prompt_ssl
64
+ @config['imap']['ssl'] = @config['imap']['ssl'] != false
65
+ imap_ssl_val = @config['imap']['ssl'] ? 1 : 2
66
+ imap_ssl_val = @prompt.select('Use SSL?', cycle: true) do |menu|
67
+ menu.default imap_ssl_val
68
+ menu.choice 'Yes', 1
69
+ menu.choice 'No', 2
70
+ end
71
+ @config['imap']['ssl'] = imap_ssl_val == 1
72
+ end
73
+
74
+ # Confirm settings with user
75
+ def prompt_confirm_save_settings
76
+ server_settings = print_settings
77
+ imap_confirm_val = @prompt.select(
78
+ "Would you like to save the following settings?\n" \
79
+ "NOTE: Any existing configuration will be overwritten\n\n" \
80
+ "#{server_settings}",
81
+ cycle: true
82
+ ) do |menu|
83
+ menu.choice 'Save', true
84
+ menu.choice 'Cancel', false
85
+ end
86
+
87
+ imap_confirm_val ? { 'save' => true, 'config' => @config } : { 'save' => false }
88
+ end
89
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConnectionConfigurationMailchain
4
+ FOLDER_STRUCTURE = { 'by_network' => 'Protocol>Network>Address', 'by_address' => 'Address>Protocol>Network' }.freeze
5
+
6
+ attr_reader :config
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ # Runs the Mailchain API configuration wizard
12
+ # Returns hash or either: { "save" => true, "config" => "{ #config }" }
13
+ # - or -
14
+ # { "save" => false }
15
+ def configuration_wizard
16
+ @prompt = TTY::Prompt.new
17
+ prompt_hostname
18
+ prompt_ssl
19
+ prompt_port
20
+ prompt_folder_format
21
+ prompt_mainnet_to_inbox
22
+ prompt_polling_interval
23
+ result = prompt_confirm_save_settings
24
+ @prompt = nil
25
+ result
26
+ end
27
+
28
+ # Prints the Mailchain API settings as output in a nice format
29
+ def print_settings
30
+ ssl = @config['mailchain']['ssl'] ? 'https' : 'http'
31
+ hostname = @config['mailchain']['hostname']
32
+ port = @config['mailchain']['port']
33
+ folders = @config['mailchain']['folders']
34
+ mainnet_inbox = @config['mailchain']['mainnet_to_inbox'] ? 'To Inbox' : 'To Mainnet Folder'
35
+ interval = @config['mailchain']['interval'].to_i > 60 ? @config['mailchain']['interval'].to_i : 60
36
+
37
+ puts "Mailchain Settings:\n" \
38
+ "-------------------\n" \
39
+ "http/https:\t#{ssl}\n" \
40
+ "Hostname:\t#{hostname}\n" \
41
+ "Port:\t\t#{port}\n" \
42
+ "API URL:\t#{ssl}://#{hostname}:#{port}/api\n" \
43
+ "Mainnet messages: #{mainnet_inbox}\n" \
44
+ "Store messages: #{FOLDER_STRUCTURE[folders]}\n" \
45
+ "Polling interval: #{interval} seconds #{'(' + (interval / 60).to_s + ' minutes)' if interval > 60}"
46
+ end
47
+
48
+ # Get Mailchain server config
49
+ def prompt_hostname
50
+ @config['mailchain']['hostname'] = @prompt.ask(
51
+ 'Enter your Mailchain client hostname (e.g. 127.0.0.1 or mailchain.example.com)',
52
+ default: @config['mailchain']['hostname'] || '127.0.0.1'
53
+ )
54
+ end
55
+
56
+ # Get Mailchain ssl status
57
+ def prompt_ssl
58
+ @config['mailchain']['ssl'] = @config['mailchain']['ssl'] != false
59
+ ssl_val = @config['mailchain']['ssl'] ? 1 : 2
60
+ ssl_val = @prompt.select('Use https (SSL)?', cycle: true) do |menu|
61
+ menu.default ssl_val
62
+ menu.choice 'https (SSL)', 1
63
+ menu.choice 'http', 2
64
+ end
65
+ @config['mailchain']['ssl'] = ssl_val == 1
66
+ end
67
+
68
+ # Get Mailchain port
69
+ def prompt_port
70
+ custom_port = @prompt.yes?('Connect to a custom port?')
71
+ case custom_port
72
+ when false && @config['mailchain']['ssl']
73
+ @config['mailchain']['port'] = 443
74
+ when false && !@config['mailchain']['ssl']
75
+ @config['mailchain']['port'] = 80
76
+ when true
77
+ @config['mailchain']['port'] = @config['mailchain']['port'] || '8080'
78
+ @config['mailchain']['port'] = @prompt.ask(
79
+ 'Enter the port to connect to the Mailchain client (e.g. 8080)',
80
+ default: @config['mailchain']['port']
81
+ )
82
+ end
83
+ end
84
+
85
+ # Folder format
86
+ def prompt_folder_format
87
+ choices = {
88
+ 1 => 'by_network',
89
+ 'by_network' => 1,
90
+
91
+ 2 => 'by_address',
92
+ 'by_address' => 2
93
+ }
94
+ folder_choice = @prompt.select(
95
+ 'How would you like to structure your folders in IMAP?',
96
+ cycle: true
97
+ ) do |menu|
98
+ menu.default choices[@config['mailchain']['folders']] || 1
99
+ menu.choice FOLDER_STRUCTURE['by_network'], 1
100
+ menu.choice FOLDER_STRUCTURE['by_address'], 2
101
+ end
102
+ @config['mailchain']['folders'] = choices[folder_choice]
103
+ end
104
+
105
+ # Mainnet to Inbox
106
+ def prompt_mainnet_to_inbox
107
+ @config['mailchain']['mainnet_to_inbox'] = @prompt.select(
108
+ "Most email clients don't alert you when messages are delivered to your folders. Would you like 'Mainnet' messages delivered to your Inbox folder so you get new message alerts?",
109
+ cycle: true
110
+ ) do |menu|
111
+ menu.choice 'Yes', true
112
+ menu.choice 'No', false
113
+ end
114
+ end
115
+
116
+ # Polling Interval
117
+ def prompt_polling_interval
118
+ @config['mailchain']['interval'] = @config['mailchain']['interval'] || '300'
119
+ @config['mailchain']['interval'] = @prompt.ask(
120
+ 'How often would you like to check for messages (in seconds)? (e.g. 300 = 5 minutes; Minimum interval is 1 minute)',
121
+ default: @config['mailchain']['interval']
122
+ )
123
+ end
124
+
125
+ # Confirm settings with user
126
+ def prompt_confirm_save_settings
127
+ settings = print_settings
128
+ mailchain_confirm_val = @prompt.select(
129
+ "Would you like to save the following settings?\n" \
130
+ "NOTE: Any existing configuration will be overwritten\n\n" \
131
+ "#{settings}",
132
+ cycle: true
133
+ ) do |menu|
134
+ menu.choice 'Save', true
135
+ menu.choice 'Cancel', false
136
+ end
137
+ mailchain_confirm_val ? { 'save' => true, 'config' => @config } : { 'save' => false }
138
+ end
139
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailchainConnectorImap
4
+ VERSION = '0.1.4'
5
+ end