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