1#!/usr/local/bin/python3.8
2#
3# Electrum - Lightweight Bitcoin Client
4# Copyright (C) 2015 Thomas Voegtlin
5#
6# Permission is hereby granted, free of charge, to any person
7# obtaining a copy of this software and associated documentation files
8# (the "Software"), to deal in the Software without restriction,
9# including without limitation the rights to use, copy, modify, merge,
10# publish, distribute, sublicense, and/or sell copies of the Software,
11# and to permit persons to whom the Software is furnished to do so,
12# subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be
15# included in all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
21# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
23# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24# SOFTWARE.
25import random
26import time
27import threading
28import base64
29from functools import partial
30import traceback
31import sys
32from typing import Set
33
34import smtplib
35import imaplib
36import email
37from email.mime.multipart import MIMEMultipart
38from email.mime.base import MIMEBase
39from email.encoders import encode_base64
40
41from PyQt5.QtCore import QObject, pyqtSignal, QThread
42from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QLineEdit,
43                             QInputDialog)
44
45from electrum.gui.qt.util import (EnterButton, Buttons, CloseButton, OkButton,
46                                  WindowModalDialog)
47from electrum.gui.qt.main_window import ElectrumWindow
48
49from electrum.plugin import BasePlugin, hook
50from electrum.paymentrequest import PaymentRequest
51from electrum.i18n import _
52from electrum.logging import Logger
53from electrum.wallet import Abstract_Wallet
54from electrum.invoices import OnchainInvoice
55
56
57class Processor(threading.Thread, Logger):
58    polling_interval = 5*60
59
60    def __init__(self, imap_server, username, password, callback):
61        threading.Thread.__init__(self)
62        Logger.__init__(self)
63        self.daemon = True
64        self.username = username
65        self.password = password
66        self.imap_server = imap_server
67        self.on_receive = callback
68        self.M = None
69        self.reset_connect_wait()
70
71    def reset_connect_wait(self):
72        self.connect_wait = 100  # ms, between failed connection attempts
73
74    def poll(self):
75        try:
76            self.M.select()
77        except:
78            return
79        typ, data = self.M.search(None, 'ALL')
80        for num in str(data[0], 'utf8').split():
81            typ, msg_data = self.M.fetch(num, '(RFC822)')
82            msg = email.message_from_bytes(msg_data[0][1])
83            p = msg.get_payload()
84            if not msg.is_multipart():
85                p = [p]
86                continue
87            for item in p:
88                if item.get_content_type() == "application/bitcoin-paymentrequest":
89                    pr_str = item.get_payload()
90                    pr_str = base64.b64decode(pr_str)
91                    self.on_receive(pr_str)
92
93    def run(self):
94        while True:
95            try:
96                self.M = imaplib.IMAP4_SSL(self.imap_server)
97                self.M.login(self.username, self.password)
98            except BaseException as e:
99                self.logger.info(f'connecting failed: {repr(e)}')
100                self.connect_wait *= 2
101            else:
102                self.reset_connect_wait()
103            # Reconnect when host changes
104            while self.M and self.M.host == self.imap_server:
105                try:
106                    self.poll()
107                except BaseException as e:
108                    self.logger.info(f'polling failed: {repr(e)}')
109                    break
110                time.sleep(self.polling_interval)
111            time.sleep(random.randint(0, self.connect_wait))
112
113    def send(self, recipient, message, payment_request):
114        msg = MIMEMultipart()
115        msg['Subject'] = message
116        msg['To'] = recipient
117        msg['From'] = self.username
118        part = MIMEBase('application', "bitcoin-paymentrequest")
119        part.set_payload(payment_request)
120        encode_base64(part)
121        part.add_header('Content-Disposition', 'attachment; filename="payreq.btc"')
122        msg.attach(part)
123        try:
124            s = smtplib.SMTP_SSL(self.imap_server, timeout=2)
125            s.login(self.username, self.password)
126            s.sendmail(self.username, [recipient], msg.as_string())
127            s.quit()
128        except BaseException as e:
129            self.logger.info(e)
130
131
132class QEmailSignalObject(QObject):
133    email_new_invoice_signal = pyqtSignal()
134
135
136class Plugin(BasePlugin):
137
138    def fullname(self):
139        return 'Email'
140
141    def description(self):
142        return _("Send and receive payment requests via email")
143
144    def is_available(self):
145        return True
146
147    def __init__(self, parent, config, name):
148        BasePlugin.__init__(self, parent, config, name)
149        self.imap_server = self.config.get('email_server', '')
150        self.username = self.config.get('email_username', '')
151        self.password = self.config.get('email_password', '')
152        if self.imap_server and self.username and self.password:
153            self.processor = Processor(self.imap_server, self.username, self.password, self.on_receive)
154            self.processor.start()
155        self.obj = QEmailSignalObject()
156        self.obj.email_new_invoice_signal.connect(self.new_invoice)
157        self.wallets = set()  # type: Set[Abstract_Wallet]
158
159    def on_receive(self, pr_str):
160        self.logger.info('received payment request')
161        self.pr = PaymentRequest(pr_str)
162        self.obj.email_new_invoice_signal.emit()
163
164    @hook
165    def load_wallet(self, wallet, main_window):
166        self.wallets |= {wallet}
167
168    @hook
169    def close_wallet(self, wallet):
170        self.wallets -= {wallet}
171
172    def new_invoice(self):
173        invoice = OnchainInvoice.from_bip70_payreq(self.pr)
174        for wallet in self.wallets:
175            wallet.save_invoice(invoice)
176        #main_window.invoice_list.update()
177
178    @hook
179    def receive_list_menu(self, window: ElectrumWindow, menu, addr):
180        menu.addAction(_("Send via e-mail"), lambda: self.send(window, addr))
181
182    def send(self, window: ElectrumWindow, addr):
183        from electrum import paymentrequest
184        req = window.wallet.receive_requests.get(addr)
185        if not isinstance(req, OnchainInvoice):
186            window.show_error("Only on-chain requests are supported.")
187            return
188        message = req.message
189        if req.bip70:
190            payload = bytes.fromhex(req.bip70)
191        else:
192            pr = paymentrequest.make_request(self.config, req)
193            payload = pr.SerializeToString()
194        if not payload:
195            return
196        recipient, ok = QInputDialog.getText(window, 'Send request', 'Email invoice to:')
197        if not ok:
198            return
199        recipient = str(recipient)
200        self.logger.info(f'sending mail to {recipient}')
201        try:
202            # FIXME this runs in the GUI thread and blocks it...
203            self.processor.send(recipient, message, payload)
204        except BaseException as e:
205            self.logger.exception('')
206            window.show_message(repr(e))
207        else:
208            window.show_message(_('Request sent.'))
209
210    def requires_settings(self):
211        return True
212
213    def settings_widget(self, window):
214        return EnterButton(_('Settings'), partial(self.settings_dialog, window))
215
216    def settings_dialog(self, window):
217        d = WindowModalDialog(window, _("Email settings"))
218        d.setMinimumSize(500, 200)
219
220        vbox = QVBoxLayout(d)
221        vbox.addWidget(QLabel(_('Server hosting your email account')))
222        grid = QGridLayout()
223        vbox.addLayout(grid)
224        grid.addWidget(QLabel('Server (IMAP)'), 0, 0)
225        server_e = QLineEdit()
226        server_e.setText(self.imap_server)
227        grid.addWidget(server_e, 0, 1)
228
229        grid.addWidget(QLabel('Username'), 1, 0)
230        username_e = QLineEdit()
231        username_e.setText(self.username)
232        grid.addWidget(username_e, 1, 1)
233
234        grid.addWidget(QLabel('Password'), 2, 0)
235        password_e = QLineEdit()
236        password_e.setText(self.password)
237        grid.addWidget(password_e, 2, 1)
238
239        vbox.addStretch()
240        vbox.addLayout(Buttons(CloseButton(d), OkButton(d)))
241
242        if not d.exec_():
243            return
244
245        server = str(server_e.text())
246        self.config.set_key('email_server', server)
247        self.imap_server = server
248
249        username = str(username_e.text())
250        self.config.set_key('email_username', username)
251        self.username = username
252
253        password = str(password_e.text())
254        self.config.set_key('email_password', password)
255        self.password = password
256
257        check_connection = CheckConnectionThread(server, username, password)
258        check_connection.connection_error_signal.connect(lambda e: window.show_message(
259            _("Unable to connect to mail server:\n {}").format(e) + "\n" +
260            _("Please check your connection and credentials.")
261        ))
262        check_connection.start()
263
264
265class CheckConnectionThread(QThread):
266    connection_error_signal = pyqtSignal(str)
267
268    def __init__(self, server, username, password):
269        super().__init__()
270        self.server = server
271        self.username = username
272        self.password = password
273
274    def run(self):
275        try:
276            conn = imaplib.IMAP4_SSL(self.server)
277            conn.login(self.username, self.password)
278        except BaseException as e:
279            self.connection_error_signal.emit(repr(e))
280