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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 WO_y@~_~Ru2KuSzrv|898@c#g#4ghQb literal 0 HcmV?d00001