telegram-sendmail 1.0.0__py3-none-any.whl
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.
- telegram_sendmail/__init__.py +13 -0
- telegram_sendmail/__main__.py +480 -0
- telegram_sendmail/client.py +201 -0
- telegram_sendmail/config.py +496 -0
- telegram_sendmail/exceptions.py +86 -0
- telegram_sendmail/parser.py +435 -0
- telegram_sendmail/py.typed +1 -0
- telegram_sendmail/smtp.py +380 -0
- telegram_sendmail/spool.py +112 -0
- telegram_sendmail-1.0.0.dist-info/METADATA +372 -0
- telegram_sendmail-1.0.0.dist-info/RECORD +14 -0
- telegram_sendmail-1.0.0.dist-info/WHEEL +4 -0
- telegram_sendmail-1.0.0.dist-info/entry_points.txt +2 -0
- telegram_sendmail-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telegram Sendmail Bridge
|
|
3
|
+
|
|
4
|
+
A drop-in `sendmail` replacement that forwards system emails to Telegram.
|
|
5
|
+
|
|
6
|
+
This package is the sole source of truth for the project version.
|
|
7
|
+
Hatchling reads `__version__` from this file via the `[tool.hatch.version]`
|
|
8
|
+
path defined in `pyproject.toml`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
__author__ = "Theodosios Divolis"
|
|
13
|
+
__license__ = "MIT"
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for telegram-sendmail.
|
|
3
|
+
|
|
4
|
+
This module is the sole owner of:
|
|
5
|
+
- `argparse` argument parsing
|
|
6
|
+
- Logging infrastructure bootstrap (syslog or console)
|
|
7
|
+
- Top-level exception-to-exit-code mapping
|
|
8
|
+
- Pipeline wiring (config -> spool -> parse -> send)
|
|
9
|
+
|
|
10
|
+
It is the only module in the package that calls `sys.exit`.
|
|
11
|
+
|
|
12
|
+
Entry point
|
|
13
|
+
-----------
|
|
14
|
+
The `main` function is registered in `pyproject.toml` under
|
|
15
|
+
`[project.scripts]` and is invoked by the `telegram-sendmail`
|
|
16
|
+
executable generated by the build backend.
|
|
17
|
+
|
|
18
|
+
Exit codes
|
|
19
|
+
----------
|
|
20
|
+
- `0` (EX_OK) — message delivered successfully.
|
|
21
|
+
- `1` (EX_ERROR) — operational failure (parse error, Telegram API error,
|
|
22
|
+
interactive invocation, or unexpected exception).
|
|
23
|
+
- `75` (EX_TEMPFAIL) — transient network failure (HTTP 429 or 5xx); the
|
|
24
|
+
calling daemon should re-queue and retry.
|
|
25
|
+
- `78` (EX_CONFIG) — configuration error (missing/invalid config file).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import logging
|
|
32
|
+
import logging.handlers
|
|
33
|
+
import sys
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
|
|
36
|
+
from telegram_sendmail import __version__
|
|
37
|
+
from telegram_sendmail.client import _RETRY_STATUS_CODES, TelegramClient
|
|
38
|
+
from telegram_sendmail.config import AppConfig, ConfigLoader
|
|
39
|
+
from telegram_sendmail.exceptions import (
|
|
40
|
+
ConfigurationError,
|
|
41
|
+
ParsingError,
|
|
42
|
+
SpoolError,
|
|
43
|
+
TelegramAPIError,
|
|
44
|
+
TelegramSendmailError,
|
|
45
|
+
)
|
|
46
|
+
from telegram_sendmail.parser import EmailParser
|
|
47
|
+
from telegram_sendmail.smtp import SMTPServer
|
|
48
|
+
from telegram_sendmail.spool import MailSpooler
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
# EX_TEMPFAIL is defined in OS on POSIX; hardcode the value for portability
|
|
53
|
+
_EX_OK = 0
|
|
54
|
+
_EX_ERROR = 1
|
|
55
|
+
_EX_TEMPFAIL = 75
|
|
56
|
+
_EX_CONFIG = 78
|
|
57
|
+
|
|
58
|
+
# Pipe-mode read cap matches the SMTP SIZE limit advertised in smtp.py so that
|
|
59
|
+
# both entry points enforce the same ceiling consistently.
|
|
60
|
+
_MAX_PIPE_SIZE: int = 10_485_760 # 10 MiB
|
|
61
|
+
_PIPE_READ_CHUNK: int = 65_536
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------
|
|
65
|
+
# Delivery pipeline
|
|
66
|
+
# --------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _deliver(
|
|
70
|
+
raw_email: str,
|
|
71
|
+
sender_override: str | None,
|
|
72
|
+
config: AppConfig,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Run the full email delivery pipeline for a single message.
|
|
76
|
+
|
|
77
|
+
Pipeline order:
|
|
78
|
+
1. **Spool** — archive the raw email. Non-fatal: a `SpoolError` is
|
|
79
|
+
logged at WARNING level and delivery continues.
|
|
80
|
+
2. **Parse** — decode MIME structure and convert body to Telegram markup.
|
|
81
|
+
3. **Format** — wrap in the Telegram message envelope with truncation.
|
|
82
|
+
4. **Send** — deliver via the Telegram Bot API.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
raw_email: Raw RFC 2822 email string.
|
|
86
|
+
sender_override: Envelope sender from `-f`/`-r` flag or SMTP
|
|
87
|
+
`MAIL FROM`. Overrides the `From` header.
|
|
88
|
+
config: Resolved `AppConfig` instance.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ParsingError: If the email body cannot be decoded.
|
|
92
|
+
TelegramAPIError: If Telegram delivery fails after all retries.
|
|
93
|
+
"""
|
|
94
|
+
spooler = MailSpooler(config)
|
|
95
|
+
try:
|
|
96
|
+
spooler.write(raw_email)
|
|
97
|
+
except SpoolError:
|
|
98
|
+
pass # Warning already emitted by MailSpooler.write
|
|
99
|
+
|
|
100
|
+
parser = EmailParser(config)
|
|
101
|
+
parsed = parser.parse(raw_email, sender_override=sender_override)
|
|
102
|
+
text = parser.format_for_telegram(parsed)
|
|
103
|
+
|
|
104
|
+
with TelegramClient(config) as client:
|
|
105
|
+
client.send(text)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _make_smtp_handler(config: AppConfig) -> Callable[[str, str | None], None]:
|
|
109
|
+
"""
|
|
110
|
+
Return a closure that binds `config` to `_deliver` for use as the
|
|
111
|
+
`SMTPServer.on_message` callback.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def handler(raw_email: str, envelope_sender: str | None) -> None:
|
|
115
|
+
_deliver(raw_email, envelope_sender, config)
|
|
116
|
+
|
|
117
|
+
return handler
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _bounded_stdin_read() -> str:
|
|
121
|
+
"""
|
|
122
|
+
Read up to `_MAX_PIPE_SIZE` characters from `sys.stdin` in chunks.
|
|
123
|
+
|
|
124
|
+
Reading in fixed-size chunks prevents a single `sys.stdin.read()` call
|
|
125
|
+
from loading an arbitrarily large payload into memory before any
|
|
126
|
+
downstream truncation occurs. Once the accumulated size exceeds
|
|
127
|
+
`_MAX_PIPE_SIZE`, the overflow is drained without buffering and a
|
|
128
|
+
`WARNING` is emitted so syslog records the event; delivery continues
|
|
129
|
+
with the collected portion.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The email content, at most `_MAX_PIPE_SIZE` characters long.
|
|
133
|
+
"""
|
|
134
|
+
chunks: list[str] = []
|
|
135
|
+
total = 0
|
|
136
|
+
oversized = False
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
chunk = sys.stdin.read(_PIPE_READ_CHUNK)
|
|
140
|
+
if not chunk:
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
if oversized:
|
|
144
|
+
# Drain without buffering so the feeding process is not blocked.
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if total + len(chunk) > _MAX_PIPE_SIZE:
|
|
148
|
+
chunks.append(chunk[: _MAX_PIPE_SIZE - total])
|
|
149
|
+
total = _MAX_PIPE_SIZE
|
|
150
|
+
oversized = True
|
|
151
|
+
else:
|
|
152
|
+
chunks.append(chunk)
|
|
153
|
+
total += len(chunk)
|
|
154
|
+
|
|
155
|
+
if oversized:
|
|
156
|
+
logger.warning(
|
|
157
|
+
"Pipe-mode stdin exceeded %d bytes; input truncated to limit",
|
|
158
|
+
_MAX_PIPE_SIZE,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return "".join(chunks)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --------------------------------------------------------------------------
|
|
165
|
+
# Logging bootstrap
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
_SYSLOG_ADDRESS = "/dev/log"
|
|
169
|
+
_SYSLOG_SOCKET_FALLBACK = "/var/run/syslog"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _TokenRedactFilter(logging.Filter):
|
|
173
|
+
"""
|
|
174
|
+
Logging filter that replaces the bot token with a safe placeholder.
|
|
175
|
+
|
|
176
|
+
Installed on every root-logger handler after the token is known so that
|
|
177
|
+
third-party loggers (`urllib3`, `requests`) cannot leak the token at
|
|
178
|
+
DEBUG level.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, token: str) -> None:
|
|
182
|
+
super().__init__()
|
|
183
|
+
self._token = token
|
|
184
|
+
self._placeholder = f"{token[:8]}[…REDACTED…]"
|
|
185
|
+
|
|
186
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
187
|
+
"""Redact the token from the fully formatted message."""
|
|
188
|
+
formatted = record.getMessage()
|
|
189
|
+
if self._token in formatted:
|
|
190
|
+
record.msg = formatted.replace(self._token, self._placeholder)
|
|
191
|
+
record.args = None
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _setup_logging(console: bool, debug: bool = False) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Configure the root logger before any module-level logger fires.
|
|
198
|
+
|
|
199
|
+
In production (`console=False`) log output goes to syslog under the
|
|
200
|
+
`LOG_MAIL` facility with the process identifier `telegram-sendmail`.
|
|
201
|
+
In console mode (`--console` flag) log output goes to `stderr` with
|
|
202
|
+
a human-readable format, which is useful for interactive debugging and
|
|
203
|
+
CI environments.
|
|
204
|
+
|
|
205
|
+
This function must be called before `ConfigLoader.load()` so that
|
|
206
|
+
configuration warnings are captured by the configured handler.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
console: Route logs to `stderr` instead of syslog.
|
|
210
|
+
debug: Set root log level to `DEBUG`; default is `INFO`.
|
|
211
|
+
"""
|
|
212
|
+
level = logging.DEBUG if debug else logging.INFO
|
|
213
|
+
|
|
214
|
+
if console:
|
|
215
|
+
handler: logging.Handler = logging.StreamHandler(sys.stderr)
|
|
216
|
+
msgfmt = "[%(levelname)s] %(name)s: %(message)s"
|
|
217
|
+
handler.setFormatter(
|
|
218
|
+
logging.Formatter(msgfmt, datefmt="%Y-%m-%d %H:%M:%S"),
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
syslog_address: str | tuple[str, int]
|
|
222
|
+
for candidate in (_SYSLOG_ADDRESS, _SYSLOG_SOCKET_FALLBACK):
|
|
223
|
+
try:
|
|
224
|
+
syslog_address = candidate
|
|
225
|
+
handler = logging.handlers.SysLogHandler(
|
|
226
|
+
address=syslog_address,
|
|
227
|
+
facility=logging.handlers.SysLogHandler.LOG_MAIL,
|
|
228
|
+
)
|
|
229
|
+
break
|
|
230
|
+
except OSError:
|
|
231
|
+
continue
|
|
232
|
+
else:
|
|
233
|
+
# Neither syslog socket is accessible; fall back to stderr so
|
|
234
|
+
# we do not silently swallow errors.
|
|
235
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
236
|
+
|
|
237
|
+
msgfmt = "telegram-sendmail[%(process)d]: %(levelname)s %(name)s: %(message)s"
|
|
238
|
+
handler.setFormatter(
|
|
239
|
+
logging.Formatter(msgfmt, datefmt="%Y-%m-%d %H:%M:%S"),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
logging.root.setLevel(level)
|
|
243
|
+
logging.root.addHandler(handler)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# --------------------------------------------------------------------------
|
|
247
|
+
# Argument parser
|
|
248
|
+
# --------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
252
|
+
"""
|
|
253
|
+
Construct the argument parser.
|
|
254
|
+
|
|
255
|
+
Sendmail-compatible flags are accepted and honoured or silently ignored
|
|
256
|
+
as documented. Positional recipient arguments (e.g.
|
|
257
|
+
`sendmail root@localhost`) are consumed via `nargs='*'` and discarded
|
|
258
|
+
so that system daemons calling `sendmail <recipient>` do not cause
|
|
259
|
+
argument parsing to fail.
|
|
260
|
+
"""
|
|
261
|
+
parser = argparse.ArgumentParser(
|
|
262
|
+
prog="telegram-sendmail",
|
|
263
|
+
description="Forward system emails to Telegram.",
|
|
264
|
+
epilog="See https://github.com/theodiv/telegram-sendmail for documentation.",
|
|
265
|
+
add_help=False,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Sendmail-compatible flags
|
|
269
|
+
parser.add_argument(
|
|
270
|
+
"-f",
|
|
271
|
+
"-r",
|
|
272
|
+
dest="sender",
|
|
273
|
+
metavar="ADDRESS",
|
|
274
|
+
help="set the envelope sender address (overrides the From header)",
|
|
275
|
+
)
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
"-s",
|
|
278
|
+
dest="subject",
|
|
279
|
+
metavar="SUBJECT",
|
|
280
|
+
help="set the email subject (overrides the Subject header)",
|
|
281
|
+
)
|
|
282
|
+
parser.add_argument(
|
|
283
|
+
"-bs",
|
|
284
|
+
action="store_true",
|
|
285
|
+
help="run in SMTP server mode (read SMTP commands from stdin)",
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument(
|
|
288
|
+
"-t",
|
|
289
|
+
action="store_true",
|
|
290
|
+
help=("extract recipients from message headers (ignored)"),
|
|
291
|
+
)
|
|
292
|
+
parser.add_argument(
|
|
293
|
+
"-i",
|
|
294
|
+
"-oi",
|
|
295
|
+
action="store_true",
|
|
296
|
+
dest="ignore_dots",
|
|
297
|
+
help="do not treat a line with only a dot as end-of-message (ignored)",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Positional recipients consumed and discarded for drop-in compatibility.
|
|
301
|
+
# System tools like cron call: sendmail -oi -t root@localhost
|
|
302
|
+
parser.add_argument(
|
|
303
|
+
"recipients",
|
|
304
|
+
nargs="*",
|
|
305
|
+
metavar="RECIPIENT",
|
|
306
|
+
help="recipient addresses (ignored; all email is forwarded to Telegram chat)",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Custom flags
|
|
310
|
+
parser.add_argument(
|
|
311
|
+
"--console",
|
|
312
|
+
action="store_true",
|
|
313
|
+
help="log to stderr instead of syslog (useful for debugging)",
|
|
314
|
+
)
|
|
315
|
+
parser.add_argument(
|
|
316
|
+
"--debug",
|
|
317
|
+
action="store_true",
|
|
318
|
+
help="set log level to DEBUG",
|
|
319
|
+
)
|
|
320
|
+
parser.add_argument(
|
|
321
|
+
"--help",
|
|
322
|
+
action="help",
|
|
323
|
+
help="display this help message and exit",
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument(
|
|
326
|
+
"--version",
|
|
327
|
+
action="version",
|
|
328
|
+
version=f"telegram-sendmail {__version__}",
|
|
329
|
+
help="output version information and exit",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return parser
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# --------------------------------------------------------------------------
|
|
336
|
+
# Main entry point
|
|
337
|
+
# --------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main() -> None:
|
|
341
|
+
"""
|
|
342
|
+
CLI entry point registered under `[project.scripts]` in `pyproject.toml`.
|
|
343
|
+
|
|
344
|
+
Dispatches to one of three operating modes:
|
|
345
|
+
|
|
346
|
+
- **SMTP mode** (`-bs`): runs `SMTPServer.run()` which speaks the
|
|
347
|
+
SMTP protocol on `stdin`/`stdout`.
|
|
348
|
+
- **Pipe mode** (stdin is not a TTY): reads a raw email from `stdin`
|
|
349
|
+
and delivers it via `_deliver()`.
|
|
350
|
+
- **Interactive mode** (stdin is a TTY): prints usage information and
|
|
351
|
+
exits with code `1`. This is not an error condition in the strict
|
|
352
|
+
sense; it just means the tool was invoked directly from a terminal.
|
|
353
|
+
"""
|
|
354
|
+
arg_parser = _build_parser()
|
|
355
|
+
args, unknown = arg_parser.parse_known_args()
|
|
356
|
+
|
|
357
|
+
_setup_logging(console=args.console, debug=args.debug)
|
|
358
|
+
|
|
359
|
+
if unknown:
|
|
360
|
+
logger.debug("Ignoring unrecognised arguments: %s", " ".join(unknown))
|
|
361
|
+
|
|
362
|
+
if args.recipients:
|
|
363
|
+
logger.debug(
|
|
364
|
+
"Recipient argument(s) received and ignored (all mail goes to "
|
|
365
|
+
"configured Telegram chat_id): %s",
|
|
366
|
+
", ".join(args.recipients),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Loading configuration must happen after logging is set up so that
|
|
370
|
+
# permission warnings from ConfigLoader are captured.
|
|
371
|
+
try:
|
|
372
|
+
config = ConfigLoader.load()
|
|
373
|
+
except ConfigurationError as exc:
|
|
374
|
+
logger.error("Configuration error: %s", exc)
|
|
375
|
+
sys.exit(_EX_CONFIG)
|
|
376
|
+
|
|
377
|
+
token_filter = _TokenRedactFilter(config.token)
|
|
378
|
+
for handler in logging.root.handlers:
|
|
379
|
+
handler.addFilter(token_filter)
|
|
380
|
+
|
|
381
|
+
if args.bs:
|
|
382
|
+
sys.exit(_run_smtp_mode(config))
|
|
383
|
+
elif not sys.stdin.isatty():
|
|
384
|
+
sys.exit(_run_pipe_mode(args.sender, args.subject, config))
|
|
385
|
+
else:
|
|
386
|
+
sys.exit(_run_interactive_mode())
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _run_smtp_mode(config: AppConfig) -> int:
|
|
390
|
+
"""Launch the SMTP state machine and map exceptions to exit codes."""
|
|
391
|
+
logger.info("Starting SMTP server mode")
|
|
392
|
+
try:
|
|
393
|
+
server = SMTPServer(config, on_message=_make_smtp_handler(config))
|
|
394
|
+
server.run()
|
|
395
|
+
return _EX_OK
|
|
396
|
+
except KeyboardInterrupt:
|
|
397
|
+
logger.info("SMTP session interrupted by user")
|
|
398
|
+
return _EX_OK
|
|
399
|
+
except TelegramSendmailError as exc:
|
|
400
|
+
logger.error("%s", exc)
|
|
401
|
+
return _EX_ERROR
|
|
402
|
+
except Exception as exc:
|
|
403
|
+
logger.error("Unexpected error in SMTP mode: %s", exc)
|
|
404
|
+
return _EX_ERROR
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _run_pipe_mode(
|
|
408
|
+
sender_override: str | None,
|
|
409
|
+
subject_override: str | None,
|
|
410
|
+
config: AppConfig,
|
|
411
|
+
) -> int:
|
|
412
|
+
"""Read email from stdin and deliver it; return an exit code."""
|
|
413
|
+
try:
|
|
414
|
+
raw_email = _bounded_stdin_read()
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
logger.error("Failed to read email from stdin: %s", exc)
|
|
417
|
+
return _EX_ERROR
|
|
418
|
+
|
|
419
|
+
# The parser accepts sender_override; subject must be injected as a
|
|
420
|
+
# synthetic header when passed via the CLI flag so that the existing
|
|
421
|
+
# parser interface is not widened unnecessarily.
|
|
422
|
+
#
|
|
423
|
+
# RFC 2822 §2.2: field names are case-insensitive, so we must perform
|
|
424
|
+
# a case-insensitive search to avoid injecting a duplicate header when
|
|
425
|
+
# the original uses a non-canonical casing (e.g. "subject:" instead of
|
|
426
|
+
# "Subject:").
|
|
427
|
+
if subject_override and "subject:" not in raw_email[:500].lower():
|
|
428
|
+
raw_email = f"Subject: {subject_override}\n{raw_email}"
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
_deliver(raw_email, sender_override, config)
|
|
432
|
+
return _EX_OK
|
|
433
|
+
except ParsingError as exc:
|
|
434
|
+
logger.error("Failed to parse email: %s", exc)
|
|
435
|
+
return _EX_ERROR
|
|
436
|
+
except TelegramAPIError as exc:
|
|
437
|
+
status = getattr(exc, "status_code", None)
|
|
438
|
+
if status is not None and status in _RETRY_STATUS_CODES:
|
|
439
|
+
# Transient HTTP failure: signal the caller to retry later.
|
|
440
|
+
logger.warning(
|
|
441
|
+
"Telegram API transient failure (HTTP %d); signalling EX_TEMPFAIL",
|
|
442
|
+
status,
|
|
443
|
+
)
|
|
444
|
+
return _EX_TEMPFAIL
|
|
445
|
+
logger.error("Telegram delivery failed: %s", exc)
|
|
446
|
+
return _EX_ERROR
|
|
447
|
+
except TelegramSendmailError as exc:
|
|
448
|
+
logger.error("%s", exc)
|
|
449
|
+
return _EX_ERROR
|
|
450
|
+
except Exception as exc:
|
|
451
|
+
logger.error("Unexpected error in pipe mode: %s", exc)
|
|
452
|
+
return _EX_ERROR
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _run_interactive_mode() -> int:
|
|
456
|
+
"""
|
|
457
|
+
Emit a helpful message when the tool is invoked directly from a terminal.
|
|
458
|
+
|
|
459
|
+
This is not a hard error, but the tool is not designed for interactive
|
|
460
|
+
use. Returns exit code 1 to signal to the shell that nothing was
|
|
461
|
+
delivered.
|
|
462
|
+
"""
|
|
463
|
+
print(
|
|
464
|
+
f"telegram-sendmail {__version__}\n"
|
|
465
|
+
"\n"
|
|
466
|
+
"This tool is designed to be invoked by system daemons as a sendmail\n"
|
|
467
|
+
"replacement, not run interactively from a terminal.\n"
|
|
468
|
+
"\n"
|
|
469
|
+
"Pipe mode: echo 'Subject: Test\\n\\nHello' | telegram-sendmail\n"
|
|
470
|
+
"SMTP mode: telegram-sendmail -bs\n"
|
|
471
|
+
"Debug mode: echo 'test' | telegram-sendmail --console --debug\n"
|
|
472
|
+
"\n"
|
|
473
|
+
"For full documentation: telegram-sendmail --help",
|
|
474
|
+
file=sys.stderr,
|
|
475
|
+
)
|
|
476
|
+
return _EX_ERROR
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
if __name__ == "__main__":
|
|
480
|
+
main()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telegram Bot API client for telegram-sendmail.
|
|
3
|
+
|
|
4
|
+
Public surface
|
|
5
|
+
--------------
|
|
6
|
+
- `TelegramClient` — sends formatted messages to a Telegram chat via the
|
|
7
|
+
Bot API, with configurable retry and timeout behaviour.
|
|
8
|
+
|
|
9
|
+
The client owns a `requests.Session` that is configured once at construction
|
|
10
|
+
time and reused for all requests within a delivery pipeline invocation. It
|
|
11
|
+
implements the context manager protocol so callers can use it with a `with`
|
|
12
|
+
statement for deterministic session cleanup.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from http import HTTPStatus
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
from requests.adapters import HTTPAdapter
|
|
22
|
+
from urllib3.util.retry import Retry
|
|
23
|
+
|
|
24
|
+
from telegram_sendmail.config import AppConfig
|
|
25
|
+
from telegram_sendmail.exceptions import TelegramAPIError
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_TELEGRAM_API_BASE = "https://api.telegram.org"
|
|
30
|
+
|
|
31
|
+
# HTTP status codes that warrant an automatic retry. 429 (Too Many Requests)
|
|
32
|
+
# is the most common transient failure from the Telegram API.
|
|
33
|
+
_RETRY_STATUS_CODES: frozenset[int] = frozenset({
|
|
34
|
+
HTTPStatus.TOO_MANY_REQUESTS,
|
|
35
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
36
|
+
HTTPStatus.BAD_GATEWAY,
|
|
37
|
+
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
38
|
+
HTTPStatus.GATEWAY_TIMEOUT,
|
|
39
|
+
}) # fmt: skip
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TelegramClient:
|
|
43
|
+
"""
|
|
44
|
+
Sends messages to the Telegram Bot API.
|
|
45
|
+
|
|
46
|
+
The underlying `requests.Session` is configured with an `HTTPAdapter`
|
|
47
|
+
backed by a `urllib3.Retry` strategy. Retry behaviour (attempt count,
|
|
48
|
+
backoff factor) is driven entirely by the values in `AppConfig`, so
|
|
49
|
+
operators can tune it for their network environment without touching code.
|
|
50
|
+
|
|
51
|
+
The class implements the context manager protocol. Although the session
|
|
52
|
+
is closed automatically when the object is garbage-collected, using the
|
|
53
|
+
context manager form is strongly preferred for production code paths
|
|
54
|
+
where deterministic cleanup matters::
|
|
55
|
+
|
|
56
|
+
with TelegramClient(config) as client:
|
|
57
|
+
client.send(message_text)
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
config: A fully resolved `AppConfig` instance.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, config: AppConfig) -> None:
|
|
64
|
+
self._token: str = config.token
|
|
65
|
+
self._chat_id: str = config.chat_id
|
|
66
|
+
self._timeout: int = config.telegram_timeout
|
|
67
|
+
self._disable_notification: bool = config.disable_notification
|
|
68
|
+
self._session: requests.Session = self._build_session(
|
|
69
|
+
max_retries=config.max_retries,
|
|
70
|
+
backoff_factor=config.backoff_factor,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> TelegramClient:
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(
|
|
77
|
+
self,
|
|
78
|
+
exc_type: type[BaseException] | None,
|
|
79
|
+
exc_val: BaseException | None,
|
|
80
|
+
exc_tb: object,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.close()
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""Release the underlying `requests.Session` and its connections."""
|
|
86
|
+
self._session.close()
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Internal helpers
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _build_session(max_retries: int, backoff_factor: float) -> requests.Session:
|
|
94
|
+
"""
|
|
95
|
+
Construct a `requests.Session` with a retry-enabled HTTPS adapter.
|
|
96
|
+
|
|
97
|
+
Retries are attempted only on the HTTP methods and status codes that
|
|
98
|
+
are safe to repeat (POST on 429/5xx). The `raise_on_status` flag is
|
|
99
|
+
left `False` here; response status is checked explicitly in `send()`
|
|
100
|
+
so error messages can be extracted from the body.
|
|
101
|
+
"""
|
|
102
|
+
retry_strategy = Retry(
|
|
103
|
+
total=max_retries,
|
|
104
|
+
backoff_factor=backoff_factor,
|
|
105
|
+
status_forcelist=list(_RETRY_STATUS_CODES),
|
|
106
|
+
allowed_methods=["POST"],
|
|
107
|
+
raise_on_status=False,
|
|
108
|
+
)
|
|
109
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
110
|
+
session = requests.Session()
|
|
111
|
+
session.mount("https://", adapter)
|
|
112
|
+
|
|
113
|
+
return session
|
|
114
|
+
|
|
115
|
+
def _api_url(self, method: str) -> str:
|
|
116
|
+
return f"{_TELEGRAM_API_BASE}/bot{self._token}/{method}"
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Public interface
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def send(self, text: str) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Send `text` to the configured Telegram chat.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
text: Fully formatted message string with Telegram HTML markup.
|
|
128
|
+
Must not exceed 4096 characters (Telegram hard limit).
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
TelegramAPIError: If the API returns a non-OK response after all
|
|
132
|
+
retry attempts are exhausted, or if the network
|
|
133
|
+
request itself fails.
|
|
134
|
+
"""
|
|
135
|
+
payload: dict[str, object] = {
|
|
136
|
+
"chat_id": self._chat_id,
|
|
137
|
+
"text": text,
|
|
138
|
+
"parse_mode": "HTML",
|
|
139
|
+
"disable_notification": self._disable_notification,
|
|
140
|
+
"link_preview_options": {"is_disabled": True},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
logger.debug(
|
|
144
|
+
"Sending message to chat_id=%s (length=%d chars)",
|
|
145
|
+
self._chat_id,
|
|
146
|
+
len(text),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
response = self._session.post(
|
|
151
|
+
url=self._api_url("sendMessage"),
|
|
152
|
+
json=payload,
|
|
153
|
+
timeout=self._timeout,
|
|
154
|
+
)
|
|
155
|
+
except requests.exceptions.Timeout as exc:
|
|
156
|
+
raise TelegramAPIError(
|
|
157
|
+
f"Telegram API request timed out after {self._timeout}s: {exc}",
|
|
158
|
+
) from exc
|
|
159
|
+
except requests.exceptions.ConnectionError as exc:
|
|
160
|
+
raise TelegramAPIError(
|
|
161
|
+
f"Failed to connect to Telegram API: {exc}",
|
|
162
|
+
) from exc
|
|
163
|
+
except requests.exceptions.RequestException as exc:
|
|
164
|
+
raise TelegramAPIError(
|
|
165
|
+
f"Telegram API request failed: {exc}",
|
|
166
|
+
) from exc
|
|
167
|
+
|
|
168
|
+
self._check_response(response)
|
|
169
|
+
logger.info("Message delivered to Telegram (chat_id=%s)", self._chat_id)
|
|
170
|
+
|
|
171
|
+
def _check_response(self, response: requests.Response) -> None:
|
|
172
|
+
"""
|
|
173
|
+
Validate the Telegram API JSON response.
|
|
174
|
+
|
|
175
|
+
The Telegram API always returns a JSON body with an `ok` boolean
|
|
176
|
+
field, even for error responses. We check that field in preference
|
|
177
|
+
to the HTTP status code because the API occasionally returns `200`
|
|
178
|
+
with `ok: false` for application-level errors (e.g. bad chat ID).
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
TelegramAPIError: If `ok` is `false` or the response body
|
|
182
|
+
cannot be decoded as JSON.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
body: dict[str, object] = response.json()
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
raise TelegramAPIError(
|
|
188
|
+
f"Telegram API returned non-JSON response "
|
|
189
|
+
f"(HTTP {response.status_code}): {response.text[:200]}",
|
|
190
|
+
status_code=response.status_code,
|
|
191
|
+
) from exc
|
|
192
|
+
|
|
193
|
+
if not body.get("ok"):
|
|
194
|
+
description = body.get("description", "no description provided")
|
|
195
|
+
error_code = body.get("error_code", response.status_code)
|
|
196
|
+
raise TelegramAPIError(
|
|
197
|
+
f"Telegram API error {error_code}: {description}",
|
|
198
|
+
status_code=(
|
|
199
|
+
int(error_code) if isinstance(error_code, (int, float)) else None
|
|
200
|
+
),
|
|
201
|
+
)
|