From 3d8a8664f2c3ef19f857fed08c4a517d6f3c1d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 9 Jan 2023 09:41:19 +0100 Subject: [PATCH 1/5] copy initial copy from my fork --- lnbits/extensions/smtp/README.md | 26 + lnbits/extensions/smtp/__init__.py | 25 + lnbits/extensions/smtp/config.json | 6 + lnbits/extensions/smtp/crud.py | 170 ++++++ lnbits/extensions/smtp/migrations.py | 35 ++ lnbits/extensions/smtp/models.py | 47 ++ lnbits/extensions/smtp/smtp.py | 90 +++ lnbits/extensions/smtp/tasks.py | 46 ++ .../smtp/templates/smtp/_api_docs.html | 23 + .../smtp/templates/smtp/display.html | 185 ++++++ .../extensions/smtp/templates/smtp/index.html | 528 ++++++++++++++++++ lnbits/extensions/smtp/views.py | 44 ++ lnbits/extensions/smtp/views_api.py | 175 ++++++ 13 files changed, 1400 insertions(+) create mode 100644 lnbits/extensions/smtp/README.md create mode 100644 lnbits/extensions/smtp/__init__.py create mode 100644 lnbits/extensions/smtp/config.json create mode 100644 lnbits/extensions/smtp/crud.py create mode 100644 lnbits/extensions/smtp/migrations.py create mode 100644 lnbits/extensions/smtp/models.py create mode 100644 lnbits/extensions/smtp/smtp.py create mode 100644 lnbits/extensions/smtp/tasks.py create mode 100644 lnbits/extensions/smtp/templates/smtp/_api_docs.html create mode 100644 lnbits/extensions/smtp/templates/smtp/display.html create mode 100644 lnbits/extensions/smtp/templates/smtp/index.html create mode 100644 lnbits/extensions/smtp/views.py create mode 100644 lnbits/extensions/smtp/views_api.py diff --git a/lnbits/extensions/smtp/README.md b/lnbits/extensions/smtp/README.md new file mode 100644 index 00000000..339f210a --- /dev/null +++ b/lnbits/extensions/smtp/README.md @@ -0,0 +1,26 @@ +

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 send on create and update +3. enjoy + +## API Endpoints + +- **Emailaddresses** + - GET /api/v1/emailaddress + - POST /api/v1/emailaddress + - PUT /api/v1/emailaddress/ + - DELETE /api/v1/emailaddress/ +- **Emails** + - GET /api/v1/email + - POST /api/v1/email/ + - GET /api/v1/email/ + - DELETE /api/v1/email/ diff --git a/lnbits/extensions/smtp/__init__.py b/lnbits/extensions/smtp/__init__.py new file mode 100644 index 00000000..1d951b31 --- /dev/null +++ b/lnbits/extensions/smtp/__init__.py @@ -0,0 +1,25 @@ +import asyncio + +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_smtp") + +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..8b2cb764 --- /dev/null +++ b/lnbits/extensions/smtp/config.json @@ -0,0 +1,6 @@ +{ + "name": "SMTP", + "short_description": "Let users send emails via your SMTP and earn sats", + "icon": "email", + "contributors": ["dni"] +} diff --git a/lnbits/extensions/smtp/crud.py b/lnbits/extensions/smtp/crud.py new file mode 100644 index 00000000..e5ab1d1f --- /dev/null +++ b/lnbits/extensions/smtp/crud.py @@ -0,0 +1,170 @@ +from http import HTTPStatus +from typing import List, Optional, Union + +from starlette.exceptions import HTTPException + +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..0b3138e9 --- /dev/null +++ b/lnbits/extensions/smtp/models.py @@ -0,0 +1,47 @@ +from fastapi.params import Query +from pydantic.main import BaseModel + + +class CreateEmailaddress(BaseModel): + wallet: str = Query(...) # type: ignore + email: str = Query(...) # type: ignore + testemail: str = Query(...) # type: ignore + smtp_server: str = Query(...) # type: ignore + smtp_user: str = Query(...) # type: ignore + smtp_password: str = Query(...) # type: ignore + smtp_port: str = Query(...) # type: ignore + description: str = Query(...) # type: ignore + anonymize: bool + cost: int = Query(..., ge=0) # type: ignore + + +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(...) # type: ignore + subject: str = Query(...) # type: ignore + receiver: str = Query(...) # type: ignore + message: str = Query(...) # type: ignore + + +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..b9a2dce3 --- /dev/null +++ b/lnbits/extensions/smtp/smtp.py @@ -0,0 +1,90 @@ +import os +import re +import socket +import sys +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +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) + + msg = MIMEMultipart("alternative") + msg["Subject"] = email.subject + msg["From"] = emailaddress.email + msg["To"] = email.receiver + + signature = "Email sent anonymiously by LNbits Sendmail extension." + text = ( + """\ + """ + + email.message + + """ + """ + + signature + + """ + """ + ) + + html = ( + """\ + + + +

""" + + 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/tasks.py b/lnbits/extensions/smtp/tasks.py new file mode 100644 index 00000000..ed569dae --- /dev/null +++ b/lnbits/extensions/smtp/tasks.py @@ -0,0 +1,46 @@ +import asyncio +from http import HTTPStatus + +import httpx +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import ( + delete_email, + get_email, + get_emailaddress, + get_emailaddress_by_email, + 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 not payment.extra or "smtp" != payment.extra.get("tag"): + # not an lnurlp invoice + 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..c7ed44de --- /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..1ba53341 --- /dev/null +++ b/lnbits/extensions/smtp/views.py @@ -0,0 +1,44 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +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) # type: ignore +): + 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..5001c1a5 --- /dev/null +++ b/lnbits/extensions/smtp/views_api.py @@ -0,0 +1,175 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +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_emailaddress_by_email, + get_emailaddresses, + get_emails, + update_emailaddress, +) +from .smtp import send_mail, valid_email + + +## EMAILS +@smtp_ext.get("/api/v1/email") +async def api_email( + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) # type: ignore +): + 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) # type: ignore +): + 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), # type: ignore + all_wallets: bool = Query(False), # type: ignore +): + 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), # type: ignore +): + 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) # type: ignore +): + 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) From e9f625f00832e7361fe71ace45b63657d1d9c75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 9 Jan 2023 09:54:17 +0100 Subject: [PATCH 2/5] mypy fixes, date issue --- lnbits/extensions/smtp/crud.py | 2 -- lnbits/extensions/smtp/models.py | 30 +++++++++++++-------------- lnbits/extensions/smtp/smtp.py | 9 ++++++-- lnbits/extensions/smtp/tasks.py | 14 ++----------- lnbits/extensions/smtp/views.py | 8 ++------ lnbits/extensions/smtp/views_api.py | 21 ++++++++----------- package-lock.json | 32 ----------------------------- 7 files changed, 34 insertions(+), 82 deletions(-) delete mode 100644 package-lock.json diff --git a/lnbits/extensions/smtp/crud.py b/lnbits/extensions/smtp/crud.py index e5ab1d1f..2eee4c3d 100644 --- a/lnbits/extensions/smtp/crud.py +++ b/lnbits/extensions/smtp/crud.py @@ -1,8 +1,6 @@ from http import HTTPStatus from typing import List, Optional, Union -from starlette.exceptions import HTTPException - from lnbits.helpers import urlsafe_short_hash from . import db diff --git a/lnbits/extensions/smtp/models.py b/lnbits/extensions/smtp/models.py index 0b3138e9..e2f3fc13 100644 --- a/lnbits/extensions/smtp/models.py +++ b/lnbits/extensions/smtp/models.py @@ -1,18 +1,18 @@ -from fastapi.params import Query -from pydantic.main import BaseModel +from fastapi import Query +from pydantic import BaseModel class CreateEmailaddress(BaseModel): - wallet: str = Query(...) # type: ignore - email: str = Query(...) # type: ignore - testemail: str = Query(...) # type: ignore - smtp_server: str = Query(...) # type: ignore - smtp_user: str = Query(...) # type: ignore - smtp_password: str = Query(...) # type: ignore - smtp_port: str = Query(...) # type: ignore - description: str = Query(...) # type: ignore + 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) # type: ignore + cost: int = Query(..., ge=0) class Emailaddresses(BaseModel): @@ -30,10 +30,10 @@ class Emailaddresses(BaseModel): class CreateEmail(BaseModel): - emailaddress_id: str = Query(...) # type: ignore - subject: str = Query(...) # type: ignore - receiver: str = Query(...) # type: ignore - message: str = Query(...) # type: ignore + emailaddress_id: str = Query(...) + subject: str = Query(...) + receiver: str = Query(...) + message: str = Query(...) class Emails(BaseModel): diff --git a/lnbits/extensions/smtp/smtp.py b/lnbits/extensions/smtp/smtp.py index b9a2dce3..a8830254 100644 --- a/lnbits/extensions/smtp/smtp.py +++ b/lnbits/extensions/smtp/smtp.py @@ -1,9 +1,9 @@ -import os import re import socket -import sys +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 @@ -25,7 +25,12 @@ 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 diff --git a/lnbits/extensions/smtp/tasks.py b/lnbits/extensions/smtp/tasks.py index ed569dae..9c544473 100644 --- a/lnbits/extensions/smtp/tasks.py +++ b/lnbits/extensions/smtp/tasks.py @@ -1,20 +1,11 @@ import asyncio -from http import HTTPStatus -import httpx from loguru import logger -from starlette.exceptions import HTTPException from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener -from .crud import ( - delete_email, - get_email, - get_emailaddress, - get_emailaddress_by_email, - set_email_paid, -) +from .crud import get_email, get_emailaddress, set_email_paid from .smtp import send_mail @@ -27,8 +18,7 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if not payment.extra or "smtp" != payment.extra.get("tag"): - # not an lnurlp invoice + if payment.extra.get("tag") != "smtp": return email = await get_email(payment.checking_id) diff --git a/lnbits/extensions/smtp/views.py b/lnbits/extensions/smtp/views.py index 1ba53341..df208a77 100644 --- a/lnbits/extensions/smtp/views.py +++ b/lnbits/extensions/smtp/views.py @@ -1,9 +1,7 @@ from http import HTTPStatus -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, HTTPException, Request from fastapi.templating import Jinja2Templates -from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse from lnbits.core.models import User @@ -16,9 +14,7 @@ templates = Jinja2Templates(directory="templates") @smtp_ext.get("/", response_class=HTMLResponse) -async def index( - request: Request, user: User = Depends(check_user_exists) # type: ignore -): +async def index(request: Request, user: User = Depends(check_user_exists)): return smtp_renderer().TemplateResponse( "smtp/index.html", {"request": request, "user": user.dict()} ) diff --git a/lnbits/extensions/smtp/views_api.py b/lnbits/extensions/smtp/views_api.py index 5001c1a5..08a05ef3 100644 --- a/lnbits/extensions/smtp/views_api.py +++ b/lnbits/extensions/smtp/views_api.py @@ -1,8 +1,6 @@ from http import HTTPStatus -from fastapi import Query -from fastapi.params import Depends -from starlette.exceptions import HTTPException +from fastapi import Depends, HTTPException, Query from lnbits.core.crud import get_user from lnbits.core.services import check_transaction_status, create_invoice @@ -17,18 +15,17 @@ from .crud import ( delete_emailaddress, get_email, get_emailaddress, - get_emailaddress_by_email, get_emailaddresses, get_emails, update_emailaddress, ) -from .smtp import send_mail, valid_email +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) # type: ignore + g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) ): wallet_ids = [g.wallet.id] if all_wallets: @@ -98,9 +95,7 @@ async def api_smtp_make_email(emailaddress_id, data: CreateEmail): @smtp_ext.delete("/api/v1/email/{email_id}") -async def api_email_delete( - email_id, g: WalletTypeInfo = Depends(get_key_type) # type: ignore -): +async def api_email_delete(email_id, g: WalletTypeInfo = Depends(get_key_type)): email = await get_email(email_id) if not email: @@ -118,8 +113,8 @@ async def api_email_delete( ## EMAILADDRESSES @smtp_ext.get("/api/v1/emailaddress") async def api_emailaddresses( - g: WalletTypeInfo = Depends(get_key_type), # type: ignore - all_wallets: bool = Query(False), # type: ignore + g: WalletTypeInfo = Depends(get_key_type), + all_wallets: bool = Query(False), ): wallet_ids = [g.wallet.id] if all_wallets: @@ -136,7 +131,7 @@ async def api_emailaddresses( async def api_emailaddress_create( data: CreateEmailaddress, emailaddress_id=None, - g: WalletTypeInfo = Depends(get_key_type), # type: ignore + g: WalletTypeInfo = Depends(get_key_type), ): if emailaddress_id: emailaddress = await get_emailaddress(emailaddress_id) @@ -158,7 +153,7 @@ async def api_emailaddress_create( @smtp_ext.delete("/api/v1/emailaddress/{emailaddress_id}") async def api_emailaddress_delete( - emailaddress_id, g: WalletTypeInfo = Depends(get_key_type) # type: ignore + emailaddress_id, g: WalletTypeInfo = Depends(get_key_type) ): emailaddress = await get_emailaddress(emailaddress_id) diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index f2ff24bd..00000000 --- a/package-lock.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "lnbits-legend", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "devDependencies": { - "prettier": "2.1.1" - } - }, - "node_modules/prettier": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.1.tgz", - "integrity": "sha512-9bY+5ZWCfqj3ghYBLxApy2zf6m+NJo5GzmLTpr9FsApsfjriNnS2dahWReHMi7qNPhhHl9SYHJs2cHZLgexNIw==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - } - } - }, - "dependencies": { - "prettier": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.1.tgz", - "integrity": "sha512-9bY+5ZWCfqj3ghYBLxApy2zf6m+NJo5GzmLTpr9FsApsfjriNnS2dahWReHMi7qNPhhHl9SYHJs2cHZLgexNIw==", - "dev": true - } - } -} From 0c1eb13d93dfb4287623ae2542eb14411fb3ab2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 9 Jan 2023 10:13:54 +0100 Subject: [PATCH 3/5] fine tunings ;) --- lnbits/extensions/smtp/README.md | 16 +------- lnbits/extensions/smtp/smtp.py | 41 ++++++++----------- .../smtp/templates/smtp/_api_docs.html | 2 +- 3 files changed, 19 insertions(+), 40 deletions(-) diff --git a/lnbits/extensions/smtp/README.md b/lnbits/extensions/smtp/README.md index 339f210a..5b7757e2 100644 --- a/lnbits/extensions/smtp/README.md +++ b/lnbits/extensions/smtp/README.md @@ -9,18 +9,6 @@ This extension allows you to setup a smtp, to offer sending emails with it for a ## Usage 1. Create new emailaddress -2. Verify if email goes to your testemail. Testmail is send on create and update -3. enjoy +2. Verify if email goes to your testemail. Testmail is sent on create and update +3. Share the link with the email form. -## API Endpoints - -- **Emailaddresses** - - GET /api/v1/emailaddress - - POST /api/v1/emailaddress - - PUT /api/v1/emailaddress/ - - DELETE /api/v1/emailaddress/ -- **Emails** - - GET /api/v1/email - - POST /api/v1/email/ - - GET /api/v1/email/ - - DELETE /api/v1/email/ diff --git a/lnbits/extensions/smtp/smtp.py b/lnbits/extensions/smtp/smtp.py index a8830254..e77bc0fa 100644 --- a/lnbits/extensions/smtp/smtp.py +++ b/lnbits/extensions/smtp/smtp.py @@ -36,32 +36,23 @@ async def send_mail(emailaddress, email): msg["To"] = email.receiver signature = "Email sent anonymiously by LNbits Sendmail extension." - text = ( - """\ - """ - + email.message - + """ - """ - + signature - + """ - """ - ) + text = f""" +{email.message} + +{signature} +""" + + html = f""" + + + +

{email.message}

+
+

{signature}

+ + +""" - html = ( - """\ - - - -

""" - + email.message - + """

-


""" - + signature - + """

- - - """ - ) part1 = MIMEText(text, "plain") part2 = MIMEText(html, "html") msg.attach(part1) diff --git a/lnbits/extensions/smtp/templates/smtp/_api_docs.html b/lnbits/extensions/smtp/templates/smtp/_api_docs.html index c7ed44de..cfb811d1 100644 --- a/lnbits/extensions/smtp/templates/smtp/_api_docs.html +++ b/lnbits/extensions/smtp/templates/smtp/_api_docs.html @@ -12,7 +12,7 @@

Charge people for using sending an email via your smtp server
More details
From e4ab966d2fd21722cc4ea0d323a030ab66d9a1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 9 Jan 2023 10:15:38 +0100 Subject: [PATCH 4/5] readd the package-lock.json from main --- package-lock.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..f2ff24bd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "lnbits-legend", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "devDependencies": { + "prettier": "2.1.1" + } + }, + "node_modules/prettier": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.1.tgz", + "integrity": "sha512-9bY+5ZWCfqj3ghYBLxApy2zf6m+NJo5GzmLTpr9FsApsfjriNnS2dahWReHMi7qNPhhHl9SYHJs2cHZLgexNIw==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + } + } + }, + "dependencies": { + "prettier": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.1.tgz", + "integrity": "sha512-9bY+5ZWCfqj3ghYBLxApy2zf6m+NJo5GzmLTpr9FsApsfjriNnS2dahWReHMi7qNPhhHl9SYHJs2cHZLgexNIw==", + "dev": true + } + } +} From ad2a6c7bc4bab9d74fe053a2f24722a1eab6cc2d Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 9 Jan 2023 11:26:20 +0000 Subject: [PATCH 5/5] Added tile --- lnbits/extensions/smtp/__init__.py | 9 +++++++++ lnbits/extensions/smtp/config.json | 4 ++-- .../smtp/static/smtp-bitcoin-email.png | Bin 0 -> 18854 bytes 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 lnbits/extensions/smtp/static/smtp-bitcoin-email.png diff --git a/lnbits/extensions/smtp/__init__.py b/lnbits/extensions/smtp/__init__.py index 1d951b31..e7419852 100644 --- a/lnbits/extensions/smtp/__init__.py +++ b/lnbits/extensions/smtp/__init__.py @@ -1,6 +1,7 @@ import asyncio from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer @@ -8,6 +9,14 @@ 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"]) diff --git a/lnbits/extensions/smtp/config.json b/lnbits/extensions/smtp/config.json index 8b2cb764..325ebfa7 100644 --- a/lnbits/extensions/smtp/config.json +++ b/lnbits/extensions/smtp/config.json @@ -1,6 +1,6 @@ { "name": "SMTP", - "short_description": "Let users send emails via your SMTP and earn sats", - "icon": "email", + "short_description": "Charge sats for sending emails", + "tile": "/smtp/static/smtp-bitcoin-email.png", "contributors": ["dni"] } 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 0000000000000000000000000000000000000000..e80b6c9aeccd0474ba735fdbfc6ea7d46b240d5f GIT binary patch literal 18854 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