diff --git a/lnbits/extensions/smtp/README.md b/lnbits/extensions/smtp/README.md new file mode 100644 index 00000000..5b7757e2 --- /dev/null +++ b/lnbits/extensions/smtp/README.md @@ -0,0 +1,14 @@ +

SMTP Extension

+ +This extension allows you to setup a smtp, to offer sending emails with it for a small fee. + +## Requirements + +- SMTP Server + +## Usage + +1. Create new emailaddress +2. Verify if email goes to your testemail. Testmail is sent on create and update +3. Share the link with the email form. + diff --git a/lnbits/extensions/smtp/__init__.py b/lnbits/extensions/smtp/__init__.py new file mode 100644 index 00000000..e7419852 --- /dev/null +++ b/lnbits/extensions/smtp/__init__.py @@ -0,0 +1,34 @@ +import asyncio + +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_smtp") + +smtp_static_files = [ + { + "path": "/smtp/static", + "app": StaticFiles(directory="lnbits/extensions/smtp/static"), + "name": "smtp_static", + } +] + +smtp_ext: APIRouter = APIRouter(prefix="/smtp", tags=["smtp"]) + + +def smtp_renderer(): + return template_renderer(["lnbits/extensions/smtp/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def smtp_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/smtp/config.json b/lnbits/extensions/smtp/config.json new file mode 100644 index 00000000..325ebfa7 --- /dev/null +++ b/lnbits/extensions/smtp/config.json @@ -0,0 +1,6 @@ +{ + "name": "SMTP", + "short_description": "Charge sats for sending emails", + "tile": "/smtp/static/smtp-bitcoin-email.png", + "contributors": ["dni"] +} diff --git a/lnbits/extensions/smtp/crud.py b/lnbits/extensions/smtp/crud.py new file mode 100644 index 00000000..2eee4c3d --- /dev/null +++ b/lnbits/extensions/smtp/crud.py @@ -0,0 +1,168 @@ +from http import HTTPStatus +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateEmail, CreateEmailaddress, Emailaddresses, Emails +from .smtp import send_mail + + +def get_test_mail(email, testemail): + return CreateEmail( + emailaddress_id=email, + subject="LNBits SMTP - Test Email", + message="This is a test email from the LNBits SMTP extension! email is working!", + receiver=testemail, + ) + + +async def create_emailaddress(data: CreateEmailaddress) -> Emailaddresses: + + emailaddress_id = urlsafe_short_hash() + + # send test mail for checking connection + email = get_test_mail(data.email, data.testemail) + await send_mail(data, email) + + await db.execute( + """ + INSERT INTO smtp.emailaddress (id, wallet, email, testemail, smtp_server, smtp_user, smtp_password, smtp_port, anonymize, description, cost) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + emailaddress_id, + data.wallet, + data.email, + data.testemail, + data.smtp_server, + data.smtp_user, + data.smtp_password, + data.smtp_port, + data.anonymize, + data.description, + data.cost, + ), + ) + + new_emailaddress = await get_emailaddress(emailaddress_id) + assert new_emailaddress, "Newly created emailaddress couldn't be retrieved" + return new_emailaddress + + +async def update_emailaddress(emailaddress_id: str, **kwargs) -> Emailaddresses: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE smtp.emailaddress SET {q} WHERE id = ?", + (*kwargs.values(), emailaddress_id), + ) + row = await db.fetchone( + "SELECT * FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,) + ) + + # send test mail for checking connection + email = get_test_mail(row.email, row.testemail) + await send_mail(row, email) + + assert row, "Newly updated emailaddress couldn't be retrieved" + return Emailaddresses(**row) + + +async def get_emailaddress(emailaddress_id: str) -> Optional[Emailaddresses]: + row = await db.fetchone( + "SELECT * FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,) + ) + return Emailaddresses(**row) if row else None + + +async def get_emailaddress_by_email(email: str) -> Optional[Emailaddresses]: + row = await db.fetchone("SELECT * FROM smtp.emailaddress WHERE email = ?", (email,)) + return Emailaddresses(**row) if row else None + + +# async def get_emailAddressByEmail(email: str) -> Optional[Emails]: +# row = await db.fetchone( +# "SELECT s.*, d.emailaddress as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.emailaddress = ?", +# (email,), +# ) +# return Subdomains(**row) if row else None + + +async def get_emailaddresses(wallet_ids: Union[str, List[str]]) -> List[Emailaddresses]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM smtp.emailaddress WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Emailaddresses(**row) for row in rows] + + +async def delete_emailaddress(emailaddress_id: str) -> None: + await db.execute("DELETE FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,)) + + +## create emails +async def create_email(payment_hash, wallet, data: CreateEmail) -> Emails: + await db.execute( + """ + INSERT INTO smtp.email (id, wallet, emailaddress_id, subject, receiver, message, paid) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + payment_hash, + wallet, + data.emailaddress_id, + data.subject, + data.receiver, + data.message, + False, + ), + ) + + new_email = await get_email(payment_hash) + assert new_email, "Newly created email couldn't be retrieved" + return new_email + + +async def set_email_paid(payment_hash: str) -> Emails: + email = await get_email(payment_hash) + if email and email.paid == False: + await db.execute( + """ + UPDATE smtp.email + SET paid = true + WHERE id = ? + """, + (payment_hash,), + ) + new_email = await get_email(payment_hash) + assert new_email, "Newly paid email couldn't be retrieved" + return new_email + + +async def get_email(email_id: str) -> Optional[Emails]: + row = await db.fetchone( + "SELECT s.*, d.email as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.id = ?", + (email_id,), + ) + return Emails(**row) if row else None + + +async def get_emails(wallet_ids: Union[str, List[str]]) -> List[Emails]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT s.*, d.email as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.wallet IN ({q})", + (*wallet_ids,), + ) + + return [Emails(**row) for row in rows] + + +async def delete_email(email_id: str) -> None: + await db.execute("DELETE FROM smtp.email WHERE id = ?", (email_id,)) diff --git a/lnbits/extensions/smtp/migrations.py b/lnbits/extensions/smtp/migrations.py new file mode 100644 index 00000000..16d50166 --- /dev/null +++ b/lnbits/extensions/smtp/migrations.py @@ -0,0 +1,35 @@ +async def m001_initial(db): + + await db.execute( + f""" + CREATE TABLE smtp.emailaddress ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + email TEXT NOT NULL, + testemail TEXT NOT NULL, + smtp_server TEXT NOT NULL, + smtp_user TEXT NOT NULL, + smtp_password TEXT NOT NULL, + smtp_port TEXT NOT NULL, + anonymize BOOLEAN NOT NULL, + description TEXT NOT NULL, + cost INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE smtp.email ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + emailaddress_id TEXT NOT NULL, + subject TEXT NOT NULL, + receiver TEXT NOT NULL, + message TEXT NOT NULL, + paid BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) diff --git a/lnbits/extensions/smtp/models.py b/lnbits/extensions/smtp/models.py new file mode 100644 index 00000000..e2f3fc13 --- /dev/null +++ b/lnbits/extensions/smtp/models.py @@ -0,0 +1,47 @@ +from fastapi import Query +from pydantic import BaseModel + + +class CreateEmailaddress(BaseModel): + wallet: str = Query(...) + email: str = Query(...) + testemail: str = Query(...) + smtp_server: str = Query(...) + smtp_user: str = Query(...) + smtp_password: str = Query(...) + smtp_port: str = Query(...) + description: str = Query(...) + anonymize: bool + cost: int = Query(..., ge=0) + + +class Emailaddresses(BaseModel): + id: str + wallet: str + email: str + testemail: str + smtp_server: str + smtp_user: str + smtp_password: str + smtp_port: str + anonymize: bool + description: str + cost: int + + +class CreateEmail(BaseModel): + emailaddress_id: str = Query(...) + subject: str = Query(...) + receiver: str = Query(...) + message: str = Query(...) + + +class Emails(BaseModel): + id: str + wallet: str + emailaddress_id: str + subject: str + receiver: str + message: str + paid: bool + time: int diff --git a/lnbits/extensions/smtp/smtp.py b/lnbits/extensions/smtp/smtp.py new file mode 100644 index 00000000..e77bc0fa --- /dev/null +++ b/lnbits/extensions/smtp/smtp.py @@ -0,0 +1,86 @@ +import re +import socket +import time +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formatdate +from http import HTTPStatus +from smtplib import SMTP_SSL as SMTP + +from loguru import logger +from starlette.exceptions import HTTPException + + +def valid_email(s): + # https://regexr.com/2rhq7 + pat = "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" + if re.match(pat, s): + return True + msg = f"SMTP - invalid email: {s}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + + +async def send_mail(emailaddress, email): + valid_email(emailaddress.email) + valid_email(email.receiver) + + ts = time.time() + date = formatdate(ts, True) + + msg = MIMEMultipart("alternative") + msg = MIMEMultipart("alternative") + msg["Date"] = date + msg["Subject"] = email.subject + msg["From"] = emailaddress.email + msg["To"] = email.receiver + + signature = "Email sent anonymiously by LNbits Sendmail extension." + text = f""" +{email.message} + +{signature} +""" + + html = f""" + + + +

{email.message}

+
+

{signature}

+ + +""" + + part1 = MIMEText(text, "plain") + part2 = MIMEText(html, "html") + msg.attach(part1) + msg.attach(part2) + + try: + conn = SMTP( + host=emailaddress.smtp_server, port=emailaddress.smtp_port, timeout=10 + ) + logger.debug("SMTP - connected to smtp server.") + # conn.set_debuglevel(True) + except: + msg = f"SMTP - error connecting to smtp server: {emailaddress.smtp_server}:{emailaddress.smtp_port}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + try: + conn.login(emailaddress.smtp_user, emailaddress.smtp_password) + logger.debug("SMTP - successful login to smtp server.") + except: + msg = f"SMTP - error login into smtp {emailaddress.smtp_user}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + try: + conn.sendmail(emailaddress.email, email.receiver, msg.as_string()) + logger.debug("SMTP - successfully send email.") + except socket.error as e: + msg = f"SMTP - error sending email: {str(e)}." + logger.error(msg) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) + finally: + conn.quit() diff --git a/lnbits/extensions/smtp/static/smtp-bitcoin-email.png b/lnbits/extensions/smtp/static/smtp-bitcoin-email.png new file mode 100644 index 00000000..e80b6c9a Binary files /dev/null and b/lnbits/extensions/smtp/static/smtp-bitcoin-email.png differ diff --git a/lnbits/extensions/smtp/tasks.py b/lnbits/extensions/smtp/tasks.py new file mode 100644 index 00000000..9c544473 --- /dev/null +++ b/lnbits/extensions/smtp/tasks.py @@ -0,0 +1,36 @@ +import asyncio + +from loguru import logger + +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import get_email, get_emailaddress, set_email_paid +from .smtp import send_mail + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") != "smtp": + return + + email = await get_email(payment.checking_id) + if not email: + logger.error("SMTP: email can not by fetched") + return + + emailaddress = await get_emailaddress(email.emailaddress_id) + if not emailaddress: + logger.error("SMTP: emailaddress can not by fetched") + return + + await payment.set_pending(False) + await send_mail(emailaddress, email) + await set_email_paid(payment_hash=payment.payment_hash) diff --git a/lnbits/extensions/smtp/templates/smtp/_api_docs.html b/lnbits/extensions/smtp/templates/smtp/_api_docs.html new file mode 100644 index 00000000..cfb811d1 --- /dev/null +++ b/lnbits/extensions/smtp/templates/smtp/_api_docs.html @@ -0,0 +1,23 @@ + + + +
+ LNBits SMTP: Get paid sats to send emails +
+

+ Charge people for using sending an email via your smtp server
+ More details +
+ Created by, dni +

+
+
+
diff --git a/lnbits/extensions/smtp/templates/smtp/display.html b/lnbits/extensions/smtp/templates/smtp/display.html new file mode 100644 index 00000000..7db4a0d6 --- /dev/null +++ b/lnbits/extensions/smtp/templates/smtp/display.html @@ -0,0 +1,185 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +

{{ email }}

+
+
{{ desc }}
+
+ + + + +

Total cost: {{ cost }} sats

+
+ Submit + Cancel +
+
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/smtp/templates/smtp/index.html b/lnbits/extensions/smtp/templates/smtp/index.html new file mode 100644 index 00000000..bf43ad7f --- /dev/null +++ b/lnbits/extensions/smtp/templates/smtp/index.html @@ -0,0 +1,528 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + + New Emailaddress + + + + + +
+
+
Emailaddresses
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Emails
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Sendmail extension +
+
+ + + {% include "smtp/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ Update Form + Create Emailaddress + Cancel +
+
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/smtp/views.py b/lnbits/extensions/smtp/views.py new file mode 100644 index 00000000..df208a77 --- /dev/null +++ b/lnbits/extensions/smtp/views.py @@ -0,0 +1,40 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Request +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import smtp_ext, smtp_renderer +from .crud import get_emailaddress + +templates = Jinja2Templates(directory="templates") + + +@smtp_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return smtp_renderer().TemplateResponse( + "smtp/index.html", {"request": request, "user": user.dict()} + ) + + +@smtp_ext.get("/{emailaddress_id}") +async def display(request: Request, emailaddress_id): + emailaddress = await get_emailaddress(emailaddress_id) + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Emailaddress does not exist." + ) + + return smtp_renderer().TemplateResponse( + "smtp/display.html", + { + "request": request, + "emailaddress_id": emailaddress.id, + "email": emailaddress.email, + "desc": emailaddress.description, + "cost": emailaddress.cost, + }, + ) diff --git a/lnbits/extensions/smtp/views_api.py b/lnbits/extensions/smtp/views_api.py new file mode 100644 index 00000000..08a05ef3 --- /dev/null +++ b/lnbits/extensions/smtp/views_api.py @@ -0,0 +1,170 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Query + +from lnbits.core.crud import get_user +from lnbits.core.services import check_transaction_status, create_invoice +from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.extensions.smtp.models import CreateEmail, CreateEmailaddress + +from . import smtp_ext +from .crud import ( + create_email, + create_emailaddress, + delete_email, + delete_emailaddress, + get_email, + get_emailaddress, + get_emailaddresses, + get_emails, + update_emailaddress, +) +from .smtp import valid_email + + +## EMAILS +@smtp_ext.get("/api/v1/email") +async def api_email( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [g.wallet.id] + if all_wallets: + user = await get_user(g.wallet.user) + if user: + wallet_ids = user.wallet_ids + return [email.dict() for email in await get_emails(wallet_ids)] + + +@smtp_ext.get("/api/v1/email/{payment_hash}") +async def api_smtp_send_email(payment_hash): + email = await get_email(payment_hash) + if not email: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="paymenthash is wrong" + ) + + emailaddress = await get_emailaddress(email.emailaddress_id) + + try: + status = await check_transaction_status(email.wallet, payment_hash) + is_paid = not status.pending + except Exception: + return {"paid": False} + if is_paid: + if emailaddress.anonymize: + await delete_email(email.id) + return {"paid": True} + return {"paid": False} + + +@smtp_ext.post("/api/v1/email/{emailaddress_id}") +async def api_smtp_make_email(emailaddress_id, data: CreateEmail): + + valid_email(data.receiver) + + emailaddress = await get_emailaddress(emailaddress_id) + # If the request is coming for the non-existant emailaddress + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Emailaddress address does not exist.", + ) + try: + memo = f"sent email from {emailaddress.email} to {data.receiver}" + if emailaddress.anonymize: + memo = "sent email" + + payment_hash, payment_request = await create_invoice( + wallet_id=emailaddress.wallet, + amount=emailaddress.cost, + memo=memo, + extra={"tag": "smtp"}, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + email = await create_email( + payment_hash=payment_hash, wallet=emailaddress.wallet, data=data + ) + + if not email: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Email could not be fetched." + ) + return {"payment_hash": payment_hash, "payment_request": payment_request} + + +@smtp_ext.delete("/api/v1/email/{email_id}") +async def api_email_delete(email_id, g: WalletTypeInfo = Depends(get_key_type)): + email = await get_email(email_id) + + if not email: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNsubdomain does not exist." + ) + + if email.wallet != g.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your email.") + + await delete_email(email_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +## EMAILADDRESSES +@smtp_ext.get("/api/v1/emailaddress") +async def api_emailaddresses( + g: WalletTypeInfo = Depends(get_key_type), + all_wallets: bool = Query(False), +): + wallet_ids = [g.wallet.id] + if all_wallets: + user = await get_user(g.wallet.user) + if user: + wallet_ids = user.wallet_ids + return [ + emailaddress.dict() for emailaddress in await get_emailaddresses(wallet_ids) + ] + + +@smtp_ext.post("/api/v1/emailaddress") +@smtp_ext.put("/api/v1/emailaddress/{emailaddress_id}") +async def api_emailaddress_create( + data: CreateEmailaddress, + emailaddress_id=None, + g: WalletTypeInfo = Depends(get_key_type), +): + if emailaddress_id: + emailaddress = await get_emailaddress(emailaddress_id) + + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Emailadress does not exist." + ) + if emailaddress.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your emailaddress." + ) + + emailaddress = await update_emailaddress(emailaddress_id, **data.dict()) + else: + emailaddress = await create_emailaddress(data=data) + return emailaddress.dict() + + +@smtp_ext.delete("/api/v1/emailaddress/{emailaddress_id}") +async def api_emailaddress_delete( + emailaddress_id, g: WalletTypeInfo = Depends(get_key_type) +): + emailaddress = await get_emailaddress(emailaddress_id) + + if not emailaddress: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Emailaddress does not exist." + ) + if emailaddress.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your Emailaddress." + ) + + await delete_emailaddress(emailaddress_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT)