js-api 0.2.100__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {js_api-0.2.100/src/js_api.egg-info → js_api-0.3.1}/PKG-INFO +2 -1
- {js_api-0.2.100 → js_api-0.3.1}/pyproject.toml +2 -1
- js_api-0.3.1/src/controllers/webhook.py +128 -0
- {js_api-0.2.100 → js_api-0.3.1/src/js_api.egg-info}/PKG-INFO +2 -1
- {js_api-0.2.100 → js_api-0.3.1}/src/js_api.egg-info/SOURCES.txt +1 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/js_api.egg-info/requires.txt +1 -0
- js_api-0.3.1/src/util/mail.py +322 -0
- js_api-0.2.100/src/controllers/webhook.py +0 -44
- {js_api-0.2.100 → js_api-0.3.1}/LICENSE +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/README.md +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/setup.cfg +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/__init__.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/api.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/controllers/__init__.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/controllers/docker.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/controllers/health_check.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/controllers/ip.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/controllers/valentines_day.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/database.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/js_api.egg-info/dependency_links.txt +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/js_api.egg-info/top_level.txt +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/models.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/util/__init__.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/util/auth.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/util/docker.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/util/healthcheck.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/src/util/logging.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/tests/test_docker.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/tests/test_health_check.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/tests/test_ip.py +0 -0
- {js_api-0.2.100 → js_api-0.3.1}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: js-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Custom API written in Python using FastAPI
|
|
5
5
|
Author-email: Jack Stockley <jack@jstockley.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/jnstockley/api
|
|
@@ -17,6 +17,7 @@ Requires-Dist: sqlmodel==0.0.47
|
|
|
17
17
|
Requires-Dist: python-dotenv==1.2.3
|
|
18
18
|
Requires-Dist: psycopg[binary]==3.3.6
|
|
19
19
|
Requires-Dist: nctalk==0.0.4
|
|
20
|
+
Requires-Dist: jinja2==3.1.6
|
|
20
21
|
Dynamic: license-file
|
|
21
22
|
|
|
22
23
|
# API
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "js-api"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Custom API written in Python using FastAPI"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Jack Stockley", email = "jack@jstockley.com"}
|
|
@@ -19,6 +19,7 @@ dependencies = [
|
|
|
19
19
|
"python-dotenv==1.2.3",
|
|
20
20
|
"psycopg[binary]==3.3.6",
|
|
21
21
|
"nctalk==0.0.4",
|
|
22
|
+
"jinja2==3.1.6",
|
|
22
23
|
]
|
|
23
24
|
[project.urls]
|
|
24
25
|
Homepage = "https://github.com/jnstockley/api"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Security
|
|
4
|
+
from nctalk import TalkMessenger
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from util.auth import get_token_header
|
|
8
|
+
from util.logging import logger
|
|
9
|
+
from util.mail import EmailSender, SMTPConfig, TemplateRenderer
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/webhook", dependencies=[Security(get_token_header)])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NextcloudTalkWebhook(BaseModel):
|
|
15
|
+
header: str = Field(..., min_length=1, max_length=200)
|
|
16
|
+
message: str = Field(..., min_length=1, max_length=32000)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PveUpsWebhook(BaseModel):
|
|
20
|
+
subject: str = Field(..., min_length=1, max_length=200)
|
|
21
|
+
body: str = Field(..., min_length=1, max_length=32000)
|
|
22
|
+
severity: str = Field(..., min_length=1, max_length=32000)
|
|
23
|
+
severity_upper: str = Field(..., min_length=1, max_length=32000)
|
|
24
|
+
facts: str = Field(..., min_length=1, max_length=32000)
|
|
25
|
+
timestamp: str = Field(..., min_length=1, max_length=32000)
|
|
26
|
+
version: str = Field(..., min_length=1, max_length=32000)
|
|
27
|
+
to: str = Field(..., min_length=1, max_length=32000)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.post("/databasus")
|
|
31
|
+
async def databasus(webhook: NextcloudTalkWebhook):
|
|
32
|
+
if webhook.header.strip() == "":
|
|
33
|
+
raise HTTPException(status_code=422, detail="header cannot be empty")
|
|
34
|
+
if webhook.message.strip() == "":
|
|
35
|
+
raise HTTPException(status_code=422, detail="message cannot be empty")
|
|
36
|
+
|
|
37
|
+
endpoint_url = os.environ.get("NEXTCLOUD_URL", "").strip()
|
|
38
|
+
token = os.environ.get("NEXTCLOUD_TALK_TOKEN", "").strip()
|
|
39
|
+
secret = os.environ.get("NEXTCLOUD_TALK_SECRET", "").strip()
|
|
40
|
+
|
|
41
|
+
if not endpoint_url or not token or not secret:
|
|
42
|
+
raise HTTPException(status_code=500, detail="Nextcloud Talk is not configured")
|
|
43
|
+
if not endpoint_url.startswith("https://"):
|
|
44
|
+
raise HTTPException(status_code=500, detail="NEXTCLOUD_URL must use https://")
|
|
45
|
+
|
|
46
|
+
messenger = TalkMessenger(endpoint_url=endpoint_url, token=token, secret=secret)
|
|
47
|
+
|
|
48
|
+
message = f"**{webhook.header}**\n{webhook.message}"
|
|
49
|
+
|
|
50
|
+
if not messenger.send(message):
|
|
51
|
+
logger.error("Failed to send message to Nextcloud Talk")
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=502, detail="Failed to send message to Nextcloud Talk"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return {"status": "sent"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/pve-ups")
|
|
60
|
+
async def pve_ups(webhook: PveUpsWebhook):
|
|
61
|
+
if not os.environ.get("SMTP_HOST") or not os.environ.get("SMTP_PORT"):
|
|
62
|
+
raise HTTPException(status_code=500, detail="SMTP is not configured")
|
|
63
|
+
if not os.environ.get("SMTP_USERNAME") or not os.environ.get("SMTP_PASSWORD"):
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=500, detail="SMTP credentials are not configured"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if webhook.body.strip() == "":
|
|
69
|
+
raise HTTPException(status_code=422, detail="body cannot be empty")
|
|
70
|
+
if webhook.facts.strip() == "":
|
|
71
|
+
raise HTTPException(status_code=422, detail="facts cannot be empty")
|
|
72
|
+
if webhook.timestamp.strip() == "":
|
|
73
|
+
raise HTTPException(status_code=422, detail="timestamp cannot be empty")
|
|
74
|
+
if webhook.subject.strip() == "":
|
|
75
|
+
raise HTTPException(status_code=422, detail="subject cannot be empty")
|
|
76
|
+
if webhook.to.strip() == "":
|
|
77
|
+
raise HTTPException(status_code=422, detail="to cannot be empty")
|
|
78
|
+
if webhook.severity.strip() == "":
|
|
79
|
+
raise HTTPException(status_code=422, detail="severity cannot be empty")
|
|
80
|
+
if webhook.version.strip() == "":
|
|
81
|
+
raise HTTPException(status_code=422, detail="version cannot be empty")
|
|
82
|
+
if webhook.severity_upper.strip() == "":
|
|
83
|
+
raise HTTPException(status_code=422, detail="severity_upper cannot be empty")
|
|
84
|
+
|
|
85
|
+
host = os.environ.get("SMTP_HOST")
|
|
86
|
+
port = int(os.environ.get("SMTP_PORT", "0"))
|
|
87
|
+
username = os.environ.get("SMTP_USERNAME")
|
|
88
|
+
password = os.environ.get("SMTP_PASSWORD")
|
|
89
|
+
|
|
90
|
+
smtp_config = SMTPConfig(host=host, port=port, username=username, password=password)
|
|
91
|
+
|
|
92
|
+
with EmailSender(smtp_config) as sender:
|
|
93
|
+
renderer = TemplateRenderer(templates_dir="templates")
|
|
94
|
+
sender.send_templated(
|
|
95
|
+
renderer=renderer,
|
|
96
|
+
sender_addr="PVE UPS <no-reply@jstockley.com>",
|
|
97
|
+
template="pve_ups_email.html",
|
|
98
|
+
subject=webhook.subject,
|
|
99
|
+
to=[webhook.to],
|
|
100
|
+
context={
|
|
101
|
+
"body": webhook.body,
|
|
102
|
+
"subject": webhook.subject,
|
|
103
|
+
"facts": parse_facts(webhook.facts),
|
|
104
|
+
"timestamp": webhook.timestamp,
|
|
105
|
+
"severity": webhook.severity,
|
|
106
|
+
"severity_upper": webhook.severity.upper(),
|
|
107
|
+
"version": webhook.version,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return {"status": "sent"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_facts(facts_text: str) -> list[dict]:
|
|
115
|
+
"""Turn PVE-UPS's rendered 'facts' string into {label, value} rows."""
|
|
116
|
+
facts = []
|
|
117
|
+
for line in facts_text.splitlines():
|
|
118
|
+
line = line.strip()
|
|
119
|
+
if not line:
|
|
120
|
+
continue
|
|
121
|
+
if ":" in line:
|
|
122
|
+
label, _, value = line.partition(":")
|
|
123
|
+
facts.append({"label": label.strip(), "value": value.strip()})
|
|
124
|
+
else:
|
|
125
|
+
facts.append(
|
|
126
|
+
{"label": "", "value": line}
|
|
127
|
+
) # no colon: show it anyway, don't drop it
|
|
128
|
+
return facts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: js-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Custom API written in Python using FastAPI
|
|
5
5
|
Author-email: Jack Stockley <jack@jstockley.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/jnstockley/api
|
|
@@ -17,6 +17,7 @@ Requires-Dist: sqlmodel==0.0.47
|
|
|
17
17
|
Requires-Dist: python-dotenv==1.2.3
|
|
18
18
|
Requires-Dist: psycopg[binary]==3.3.6
|
|
19
19
|
Requires-Dist: nctalk==0.0.4
|
|
20
|
+
Requires-Dist: jinja2==3.1.6
|
|
20
21
|
Dynamic: license-file
|
|
21
22
|
|
|
22
23
|
# API
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
email_sender.py
|
|
3
|
+
|
|
4
|
+
A small, secure, dependency-free utility for sending email through a local
|
|
5
|
+
(or remote) SMTP server using Python's standard library.
|
|
6
|
+
|
|
7
|
+
Design goals:
|
|
8
|
+
- Secure by default (TLS/STARTTLS, no plaintext credentials in logs)
|
|
9
|
+
- Modern Python (type hints, dataclasses, context manager, pathlib)
|
|
10
|
+
- Performant for simple use (single persistent connection per batch via
|
|
11
|
+
the context manager, so you don't reconnect for every message)
|
|
12
|
+
|
|
13
|
+
Templating: HTML/text bodies can be rendered from Jinja2 templates stored
|
|
14
|
+
on disk via TemplateRenderer + EmailSender.send_templated(). Requires the
|
|
15
|
+
'jinja2' package: pip install jinja2
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import html as html_lib
|
|
21
|
+
import logging
|
|
22
|
+
import mimetypes
|
|
23
|
+
import re
|
|
24
|
+
import smtplib
|
|
25
|
+
import ssl
|
|
26
|
+
from collections.abc import Iterable, Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from email.message import EmailMessage
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Self
|
|
31
|
+
|
|
32
|
+
import jinja2
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SMTPConfig:
|
|
39
|
+
host: str = "localhost"
|
|
40
|
+
port: int = 587
|
|
41
|
+
username: str | None = None
|
|
42
|
+
password: str | None = None
|
|
43
|
+
use_ssl: bool = False # True -> SMTPS (implicit TLS, usually port 465)
|
|
44
|
+
use_starttls: bool = True # True -> upgrade plaintext connection (usually port 587)
|
|
45
|
+
timeout: float = 10.0
|
|
46
|
+
# Set to False only for local/dev servers with self-signed certs.
|
|
47
|
+
verify_tls: bool = True
|
|
48
|
+
# Refuse to negotiate anything older than TLS 1.2 (TLS 1.3 will be used
|
|
49
|
+
# automatically whenever both sides support it, since it's preferred by
|
|
50
|
+
# the default context's own ordering).
|
|
51
|
+
minimum_tls_version: ssl.TLSVersion = ssl.TLSVersion.TLSv1_2
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EmailSendError(RuntimeError):
|
|
55
|
+
"""Raised when a message could not be delivered."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _html_to_text(html_body: str) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Best-effort plain-text fallback derived from an HTML body, for callers
|
|
64
|
+
who only supply an HTML template. Not a full HTML renderer -- good
|
|
65
|
+
enough for a fallback that non-HTML mail clients will show, not a
|
|
66
|
+
substitute for an intentionally written text template.
|
|
67
|
+
"""
|
|
68
|
+
text = re.sub(r"(?is)<(script|style)\b.*?</\1>", "", html_body)
|
|
69
|
+
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
|
|
70
|
+
text = re.sub(r"(?i)</p>", "\n\n", text)
|
|
71
|
+
text = _TAG_RE.sub("", text)
|
|
72
|
+
text = html_lib.unescape(text)
|
|
73
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
74
|
+
return "\n".join(line for line in lines if line) or " "
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TemplateRenderer:
|
|
78
|
+
"""
|
|
79
|
+
Renders email bodies from Jinja2 templates stored in a directory.
|
|
80
|
+
|
|
81
|
+
Expected layout, e.g.:
|
|
82
|
+
templates/
|
|
83
|
+
welcome.html # HTML body -- autoescaped
|
|
84
|
+
welcome.txt # optional plain-text counterpart -- not autoescaped
|
|
85
|
+
|
|
86
|
+
HTML autoescaping is always on for .html/.htm templates, so values in
|
|
87
|
+
`context` (a user's name, a comment, anything not written by you) can't
|
|
88
|
+
inject markup or script into the rendered email. Undefined variables
|
|
89
|
+
raise instead of silently rendering as empty strings, so a typo'd or
|
|
90
|
+
missing field is caught at render time rather than mailed out blank.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, templates_dir: str | Path, strict: bool = True):
|
|
94
|
+
self.templates_dir = Path(templates_dir)
|
|
95
|
+
self._env = jinja2.Environment(
|
|
96
|
+
loader=jinja2.FileSystemLoader(str(self.templates_dir)),
|
|
97
|
+
autoescape=jinja2.select_autoescape(enabled_extensions=("html", "htm")),
|
|
98
|
+
undefined=jinja2.StrictUndefined if strict else jinja2.Undefined,
|
|
99
|
+
trim_blocks=True,
|
|
100
|
+
lstrip_blocks=True,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def render(self, template_name: str, context: dict[str, Any]) -> str:
|
|
104
|
+
try:
|
|
105
|
+
template = self._env.get_template(template_name)
|
|
106
|
+
except jinja2.TemplateNotFound as exc:
|
|
107
|
+
raise FileNotFoundError(
|
|
108
|
+
f"Template {template_name!r} not found in {self.templates_dir}"
|
|
109
|
+
) from exc
|
|
110
|
+
try:
|
|
111
|
+
return template.render(**context)
|
|
112
|
+
except jinja2.UndefinedError as exc:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Missing field rendering {template_name!r}: {exc}"
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class EmailSender:
|
|
119
|
+
"""
|
|
120
|
+
Secure SMTP email sender with connection reuse.
|
|
121
|
+
|
|
122
|
+
Usage:
|
|
123
|
+
config = SMTPConfig(host="localhost", port=587, username="me@example.com",
|
|
124
|
+
password="app-password")
|
|
125
|
+
|
|
126
|
+
with EmailSender(config) as sender:
|
|
127
|
+
sender.send(
|
|
128
|
+
sender_addr="me@example.com",
|
|
129
|
+
to=["you@example.com"],
|
|
130
|
+
subject="Hello",
|
|
131
|
+
body="Plain text body",
|
|
132
|
+
html_body="<p>Optional HTML body</p>",
|
|
133
|
+
attachments=["report.pdf"],
|
|
134
|
+
)
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, config: SMTPConfig):
|
|
138
|
+
self.config = config
|
|
139
|
+
self._connection: smtplib.SMTP | None = None
|
|
140
|
+
|
|
141
|
+
# -- connection lifecycle -------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def connect(self) -> None:
|
|
144
|
+
if self._connection is not None:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
not self.config.use_ssl
|
|
149
|
+
and not self.config.use_starttls
|
|
150
|
+
and self.config.password
|
|
151
|
+
):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Refusing to send credentials over an unencrypted connection: "
|
|
154
|
+
"set use_ssl=True or use_starttls=True, or remove the password "
|
|
155
|
+
"if you really intend to connect in plaintext."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
context = ssl.create_default_context()
|
|
159
|
+
context.minimum_version = self.config.minimum_tls_version
|
|
160
|
+
if not self.config.verify_tls:
|
|
161
|
+
# Only intended for trusted local/dev SMTP servers.
|
|
162
|
+
context.check_hostname = False
|
|
163
|
+
context.verify_mode = ssl.CERT_NONE
|
|
164
|
+
logger.warning(
|
|
165
|
+
"TLS certificate verification is disabled for %s", self.config.host
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if self.config.use_ssl:
|
|
169
|
+
conn: smtplib.SMTP = smtplib.SMTP_SSL(
|
|
170
|
+
self.config.host,
|
|
171
|
+
self.config.port,
|
|
172
|
+
timeout=self.config.timeout,
|
|
173
|
+
context=context,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
conn = smtplib.SMTP(
|
|
177
|
+
self.config.host, self.config.port, timeout=self.config.timeout
|
|
178
|
+
)
|
|
179
|
+
conn.ehlo()
|
|
180
|
+
if self.config.use_starttls:
|
|
181
|
+
conn.starttls(context=context)
|
|
182
|
+
conn.ehlo()
|
|
183
|
+
|
|
184
|
+
if self.config.username and self.config.password:
|
|
185
|
+
conn.login(self.config.username, self.config.password)
|
|
186
|
+
|
|
187
|
+
self._connection = conn
|
|
188
|
+
logger.info(
|
|
189
|
+
"Connected to SMTP server %s:%s", self.config.host, self.config.port
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
if self._connection is not None:
|
|
194
|
+
try:
|
|
195
|
+
self._connection.quit()
|
|
196
|
+
except smtplib.SMTPException, OSError:
|
|
197
|
+
self._connection.close()
|
|
198
|
+
finally:
|
|
199
|
+
self._connection = None
|
|
200
|
+
|
|
201
|
+
def __enter__(self) -> Self:
|
|
202
|
+
self.connect()
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
206
|
+
self.close()
|
|
207
|
+
|
|
208
|
+
# -- message construction / sending ----------------------------------------
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _build_message(
|
|
212
|
+
sender_addr: str,
|
|
213
|
+
to: Sequence[str],
|
|
214
|
+
subject: str,
|
|
215
|
+
body: str,
|
|
216
|
+
html_body: str | None = None,
|
|
217
|
+
cc: Sequence[str] | None = None,
|
|
218
|
+
bcc: Sequence[str] | None = None,
|
|
219
|
+
reply_to: str | None = None,
|
|
220
|
+
attachments: Iterable[str | Path] | None = None,
|
|
221
|
+
) -> tuple[EmailMessage, list[str]]:
|
|
222
|
+
msg = EmailMessage()
|
|
223
|
+
msg["From"] = sender_addr
|
|
224
|
+
msg["To"] = ", ".join(to)
|
|
225
|
+
msg["Subject"] = subject
|
|
226
|
+
if cc:
|
|
227
|
+
msg["Cc"] = ", ".join(cc)
|
|
228
|
+
if reply_to:
|
|
229
|
+
msg["Reply-To"] = reply_to
|
|
230
|
+
|
|
231
|
+
msg.set_content(body)
|
|
232
|
+
if html_body:
|
|
233
|
+
msg.add_alternative(html_body, subtype="html")
|
|
234
|
+
|
|
235
|
+
for path in attachments or []:
|
|
236
|
+
path = Path(path)
|
|
237
|
+
data = path.read_bytes()
|
|
238
|
+
ctype, _ = mimetypes.guess_type(path.name)
|
|
239
|
+
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)
|
|
240
|
+
msg.add_attachment(
|
|
241
|
+
data, maintype=maintype, subtype=subtype, filename=path.name
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
all_recipients = list(to) + list(cc or []) + list(bcc or [])
|
|
245
|
+
return msg, all_recipients
|
|
246
|
+
|
|
247
|
+
def send(
|
|
248
|
+
self,
|
|
249
|
+
sender_addr: str,
|
|
250
|
+
to: Sequence[str],
|
|
251
|
+
subject: str,
|
|
252
|
+
body: str,
|
|
253
|
+
html_body: str | None = None,
|
|
254
|
+
cc: Sequence[str] | None = None,
|
|
255
|
+
bcc: Sequence[str] | None = None,
|
|
256
|
+
reply_to: str | None = None,
|
|
257
|
+
attachments: Iterable[str | Path] | None = None,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Send a single email. Opens a connection automatically if not already open."""
|
|
260
|
+
opened_here = False
|
|
261
|
+
if self._connection is None:
|
|
262
|
+
self.connect()
|
|
263
|
+
opened_here = True
|
|
264
|
+
|
|
265
|
+
msg, recipients = self._build_message(
|
|
266
|
+
sender_addr, to, subject, body, html_body, cc, bcc, reply_to, attachments
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
assert self._connection is not None
|
|
271
|
+
self._connection.send_message(
|
|
272
|
+
msg, from_addr=sender_addr, to_addrs=recipients
|
|
273
|
+
)
|
|
274
|
+
logger.info("Sent email to %s recipients", len(recipients))
|
|
275
|
+
except smtplib.SMTPException as exc:
|
|
276
|
+
raise EmailSendError(f"Failed to send email: {exc}") from exc
|
|
277
|
+
finally:
|
|
278
|
+
if opened_here:
|
|
279
|
+
self.close()
|
|
280
|
+
|
|
281
|
+
def send_templated(
|
|
282
|
+
self,
|
|
283
|
+
renderer: TemplateRenderer,
|
|
284
|
+
sender_addr: str,
|
|
285
|
+
to: Sequence[str],
|
|
286
|
+
subject: str,
|
|
287
|
+
template: str,
|
|
288
|
+
context: dict[str, Any],
|
|
289
|
+
text_template: str | None = None,
|
|
290
|
+
cc: Sequence[str] | None = None,
|
|
291
|
+
bcc: Sequence[str] | None = None,
|
|
292
|
+
reply_to: str | None = None,
|
|
293
|
+
attachments: Iterable[str | Path] | None = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Render `template` (an HTML file in renderer.templates_dir) with
|
|
297
|
+
`context` and send it.
|
|
298
|
+
|
|
299
|
+
If `text_template` is given, that file is rendered too and used as
|
|
300
|
+
the plain-text part (recommended -- write copy for text clients
|
|
301
|
+
deliberately rather than relying on auto-stripped HTML). Otherwise
|
|
302
|
+
a plain-text fallback is derived automatically from the rendered
|
|
303
|
+
HTML.
|
|
304
|
+
"""
|
|
305
|
+
html_body = renderer.render(template, context)
|
|
306
|
+
body = (
|
|
307
|
+
renderer.render(text_template, context)
|
|
308
|
+
if text_template
|
|
309
|
+
else _html_to_text(html_body)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
self.send(
|
|
313
|
+
sender_addr=sender_addr,
|
|
314
|
+
to=to,
|
|
315
|
+
subject=subject,
|
|
316
|
+
body=body,
|
|
317
|
+
html_body=html_body,
|
|
318
|
+
cc=cc,
|
|
319
|
+
bcc=bcc,
|
|
320
|
+
reply_to=reply_to,
|
|
321
|
+
attachments=attachments,
|
|
322
|
+
)
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
from fastapi import APIRouter, HTTPException, Security
|
|
4
|
-
from nctalk import TalkMessenger
|
|
5
|
-
from pydantic import BaseModel, Field
|
|
6
|
-
|
|
7
|
-
from util.auth import get_token_header
|
|
8
|
-
from util.logging import logger
|
|
9
|
-
|
|
10
|
-
router = APIRouter(prefix="/webhook", dependencies=[Security(get_token_header)])
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class NextcloudTalkWebhook(BaseModel):
|
|
14
|
-
header: str = Field(..., min_length=1, max_length=200)
|
|
15
|
-
message: str = Field(..., min_length=1, max_length=32000)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@router.post("/databasus")
|
|
19
|
-
async def databasus(webhook: NextcloudTalkWebhook):
|
|
20
|
-
if webhook.header.strip() == "":
|
|
21
|
-
raise HTTPException(status_code=422, detail="header cannot be empty")
|
|
22
|
-
if webhook.message.strip() == "":
|
|
23
|
-
raise HTTPException(status_code=422, detail="message cannot be empty")
|
|
24
|
-
|
|
25
|
-
endpoint_url = os.environ.get("NEXTCLOUD_URL", "").strip()
|
|
26
|
-
token = os.environ.get("NEXTCLOUD_TALK_TOKEN", "").strip()
|
|
27
|
-
secret = os.environ.get("NEXTCLOUD_TALK_SECRET", "").strip()
|
|
28
|
-
|
|
29
|
-
if not endpoint_url or not token or not secret:
|
|
30
|
-
raise HTTPException(status_code=500, detail="Nextcloud Talk is not configured")
|
|
31
|
-
if not endpoint_url.startswith("https://"):
|
|
32
|
-
raise HTTPException(status_code=500, detail="NEXTCLOUD_URL must use https://")
|
|
33
|
-
|
|
34
|
-
messenger = TalkMessenger(endpoint_url=endpoint_url, token=token, secret=secret)
|
|
35
|
-
|
|
36
|
-
message = f"**{webhook.header}**\n{webhook.message}"
|
|
37
|
-
|
|
38
|
-
if not messenger.send(message):
|
|
39
|
-
logger.error("Failed to send message to Nextcloud Talk")
|
|
40
|
-
raise HTTPException(
|
|
41
|
-
status_code=502, detail="Failed to send message to Nextcloud Talk"
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
return {"status": "sent"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|