s2k-cli 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marco Fura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.3
2
+ Name: s2k-cli
3
+ Version: 0.1.0
4
+ Summary: Send local files to your Kindle from the command line.
5
+ License: MIT
6
+ Keywords: kindle,send-to-kindle,ebook,email,cli
7
+ Author: Marco Fura
8
+ Author-email: mjfura27@gmail.com
9
+ Requires-Python: >=3.13,<4.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Communications :: Email
17
+ Requires-Dist: email-validator (>=2.3,<3.0)
18
+ Requires-Dist: pydantic (>=2.13,<3.0)
19
+ Requires-Dist: pydantic-settings (>=2.15,<3.0)
20
+ Requires-Dist: python-dotenv (>=1.2,<2.0)
21
+ Requires-Dist: typer (>=0.27,<1.0)
22
+ Project-URL: Homepage, https://github.com/mjfura/send-2-kindle
23
+ Project-URL: Issues, https://github.com/mjfura/send-2-kindle/issues
24
+ Project-URL: Repository, https://github.com/mjfura/send-2-kindle
25
+ Description-Content-Type: text/markdown
26
+
27
+ # s2k — send files to your Kindle
28
+
29
+ `s2k` emails documents to your Kindle through Amazon's Send to Kindle service, one email per file.
30
+
31
+ ```bash
32
+ s2k send book.epub paper.pdf notes.docx
33
+ ```
34
+
35
+ Supported: `.pdf .epub .doc .docx .txt .rtf .html .htm .jpg .jpeg .png .gif .bmp`, up to 50 MB
36
+ each (your email provider may allow less: Gmail rejects files above roughly 18 MB).
37
+
38
+ ## Install
39
+
40
+ Requires Python 3.13+.
41
+
42
+ ```bash
43
+ pipx install s2k-cli # or: uv tool install s2k-cli
44
+ ```
45
+
46
+ ## Set up
47
+
48
+ 1. **In Amazon** (*Manage Your Content and Devices → Preferences → Personal Document Settings*):
49
+ copy your `@kindle.com` address and add the email you will send from to the *Approved Personal
50
+ Document E-mail List*.
51
+ 2. **Gmail users:** create an app password at <https://myaccount.google.com/apppasswords>
52
+ (requires 2-Step Verification). Your normal Gmail password will not work.
53
+ 3. Run the wizard in your terminal — it asks for the values, hides the password, saves them to
54
+ `~/.config/s2k/config.env` with private permissions and offers to test the login:
55
+ ```bash
56
+ s2k init
57
+ ```
58
+ 4. Check that everything is ready at any time (sends nothing):
59
+ ```bash
60
+ s2k doctor
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ s2k send FILE... # send files, one email each
67
+ s2k doctor # check configuration and SMTP login
68
+ s2k init # create or update the configuration
69
+ s2k --version
70
+ ```
71
+
72
+ Exit codes: `0` success · `1` something failed (see the report) · `2` invalid configuration or usage.
73
+
74
+ "Sent" means your email provider accepted the message. If Amazon rejects it (for example, the
75
+ sender is not approved), Amazon emails you.
76
+
77
+ ## Configuration
78
+
79
+ Read from, highest priority first: environment variables → the file in `S2K_CONFIG_FILE` →
80
+ `$XDG_CONFIG_HOME/s2k/config.env` → `~/.config/s2k/config.env`.
81
+
82
+ | Variable | Required | Default |
83
+ |---|---|---|
84
+ | `S2K_KINDLE_EMAIL` | yes | — |
85
+ | `S2K_SENDER_EMAIL` | yes | — |
86
+ | `S2K_SMTP_PASSWORD` | yes | — |
87
+ | `S2K_SMTP_HOST` | no | `smtp.gmail.com` |
88
+ | `S2K_SMTP_PORT` | no | `587` |
89
+ | `S2K_SMTP_SECURITY` | no | `starttls` (`ssl` for port 465) |
90
+ | `S2K_SMTP_USERNAME` | no | the sender address |
91
+
92
+ ## Troubleshooting
93
+
94
+ | `s2k doctor` says | Do this |
95
+ |---|---|
96
+ | No configuration found | Run `s2k init` |
97
+ | authentication failed … app password | Create a Gmail app password and run `s2k init` again |
98
+ | could not connect | Check `S2K_SMTP_HOST`, `S2K_SMTP_PORT` and `S2K_SMTP_SECURITY` |
99
+ | Config file is readable by other users | `chmod 600 ~/.config/s2k/config.env` |
100
+
101
+ ## License
102
+
103
+ MIT
104
+
@@ -0,0 +1,15 @@
1
+ send_2_kindle/__init__.py,sha256=hcEQ9AMl6lweP6BeqRKfF_glPmyi4BBv0o2mDQqD_2U,317
2
+ send_2_kindle/__main__.py,sha256=-uxAIKBG9ZYJ579-J7vjZp0t3vCQKZGcRYYAOKkdItQ,57
3
+ send_2_kindle/config.py,sha256=PdIUIkxOyz_4kZ3TVRX4cGI45l_gM_M-lRER6KVo75M,4262
4
+ send_2_kindle/constants.py,sha256=q_6hqepMXVwSyI1oNrHqMPwCIZBmVv68RRaV7JLf8UE,698
5
+ send_2_kindle/doctor.py,sha256=gBJ_PSAMW86Oyn8KmWmqCVoWkkD9N3xsbrkutitGYGI,4172
6
+ send_2_kindle/errors.py,sha256=JDJi7nMoXkYYtBXjjmsqpxjPJUgo0y5zAt805pBT__U,673
7
+ send_2_kindle/mailer.py,sha256=9V4Gbu7eL8Al7FW985Y6umvxDmgXyqrakBnWrazcrk8,6199
8
+ send_2_kindle/main.py,sha256=MEquGFcTg1MLHzm0_m6-57zZdF7b0qPNMYT4HTqx3QY,3630
9
+ send_2_kindle/validation.py,sha256=nDJQTX9xiArQcMzzDsmnX-zBX36-XQE9u1EoY7sDjSQ,1490
10
+ send_2_kindle/wizard.py,sha256=LdO8iKxoiyRq_LxEFmFW8U1gvCscWTkRBjVhrF0lY-I,4812
11
+ s2k_cli-0.1.0.dist-info/LICENSE,sha256=-z9vgEIhGUT7DJKb2AzLJVauxgLJdqYF9nW-oT_uvEE,1067
12
+ s2k_cli-0.1.0.dist-info/METADATA,sha256=QLRupKcncWU9N1ckGL3tCcylmk8BaSJCef_doQ4kXvk,3567
13
+ s2k_cli-0.1.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
14
+ s2k_cli-0.1.0.dist-info/entry_points.txt,sha256=HMu8Puvu6Fiv3QIv45LKT_tpQeTc4Jd-TBZLFCAVIQE,46
15
+ s2k_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ s2k=send_2_kindle.main:app
3
+
@@ -0,0 +1,10 @@
1
+ """s2k: send local files to your Kindle through the Send to Kindle email service."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ DISTRIBUTION_NAME = "s2k-cli"
6
+
7
+
8
+ def installed_version() -> str:
9
+ """Version of the installed s2k-cli distribution (single source: pyproject.toml)."""
10
+ return version(DISTRIBUTION_NAME)
@@ -0,0 +1,3 @@
1
+ from send_2_kindle.main import app
2
+
3
+ app(prog_name="s2k")
@@ -0,0 +1,116 @@
1
+ """Configuration loaded from S2K_* environment variables and the user's config file."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Annotated, Any, Final, Literal
6
+
7
+ from dotenv import dotenv_values
8
+ from pydantic import EmailStr, Field, SecretStr, ValidationError, field_validator
9
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
10
+
11
+ from send_2_kindle import constants
12
+ from send_2_kindle.errors import ConfigError
13
+
14
+ ENV_PREFIX: Final[str] = "S2K_"
15
+ CONFIG_FILE_VARIABLE: Final[str] = "S2K_CONFIG_FILE"
16
+ REQUIRED_VARIABLES: Final[tuple[str, ...]] = (
17
+ "S2K_KINDLE_EMAIL",
18
+ "S2K_SENDER_EMAIL",
19
+ "S2K_SMTP_PASSWORD",
20
+ )
21
+
22
+ # Field types shared by Settings and the `s2k init` wizard, so both validate the same way.
23
+ SmtpHost = Annotated[str, Field(min_length=1)]
24
+ SmtpPort = Annotated[int, Field(ge=1, le=65535)]
25
+ SmtpSecurity = Literal["starttls", "ssl"]
26
+
27
+
28
+ def config_file_path() -> Path:
29
+ """Return the config file s2k reads (it may not exist).
30
+
31
+ $S2K_CONFIG_FILE, else $XDG_CONFIG_HOME/s2k/config.env, else ~/.config/s2k/config.env.
32
+ """
33
+ override = os.environ.get(CONFIG_FILE_VARIABLE)
34
+ if override:
35
+ return Path(override).expanduser()
36
+ base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
37
+ return Path(base).expanduser() / "s2k" / "config.env"
38
+
39
+
40
+ class Settings(BaseSettings):
41
+ """Validated s2k configuration. Real environment variables override the config file."""
42
+
43
+ model_config = SettingsConfigDict(
44
+ env_prefix=ENV_PREFIX, env_file_encoding="utf-8", extra="ignore"
45
+ )
46
+
47
+ kindle_email: EmailStr
48
+ sender_email: EmailStr
49
+ smtp_password: SecretStr
50
+ smtp_host: SmtpHost = constants.GMAIL_SMTP_HOST
51
+ smtp_port: SmtpPort = 587
52
+ smtp_security: SmtpSecurity = "starttls"
53
+ smtp_username: str | None = None
54
+
55
+ @field_validator("smtp_password")
56
+ @classmethod
57
+ def _password_not_empty(cls, value: SecretStr) -> SecretStr:
58
+ if not value.get_secret_value():
59
+ raise ValueError("must not be empty")
60
+ return value
61
+
62
+ @classmethod
63
+ def settings_customise_sources(
64
+ cls,
65
+ settings_cls: type[BaseSettings],
66
+ init_settings: PydanticBaseSettingsSource,
67
+ env_settings: PydanticBaseSettingsSource,
68
+ dotenv_settings: PydanticBaseSettingsSource,
69
+ file_secret_settings: PydanticBaseSettingsSource,
70
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
71
+ # Environment variables win; config-file values arrive as init kwargs (see load_settings).
72
+ return (env_settings, init_settings)
73
+
74
+ @property
75
+ def login_username(self) -> str:
76
+ """SMTP login: S2K_SMTP_USERNAME, or the sender address when unset or empty."""
77
+ return self.smtp_username or self.sender_email
78
+
79
+
80
+ def read_config_file(path: Path) -> dict[str, str]:
81
+ """Return the S2K_* entries of a config file, read literally (no ${VAR} expansion).
82
+
83
+ Raises OSError if the file exists but cannot be read; a missing file gives {}.
84
+ """
85
+ if not path.is_file():
86
+ return {}
87
+ values = dotenv_values(path, encoding="utf-8", interpolate=False)
88
+ return {
89
+ key.upper(): value
90
+ for key, value in values.items()
91
+ if value is not None and key.upper().startswith(ENV_PREFIX)
92
+ }
93
+
94
+
95
+ def _describe(error: ValidationError, path: Path) -> str:
96
+ lines = [f"Invalid configuration (check {path} or your environment variables):"]
97
+ for item in error.errors():
98
+ field = str(item["loc"][0]) if item["loc"] else "?"
99
+ reason = "is required" if item["type"] == "missing" else item["msg"]
100
+ lines.append(f" {ENV_PREFIX}{field.upper()}: {reason}")
101
+ return "\n".join(lines)
102
+
103
+
104
+ def load_settings() -> Settings:
105
+ """Load settings from the environment and config_file_path(), raising ConfigError if invalid."""
106
+ path = config_file_path()
107
+ try:
108
+ file_values: dict[str, Any] = {
109
+ key.removeprefix(ENV_PREFIX).lower(): value
110
+ for key, value in read_config_file(path).items()
111
+ }
112
+ return Settings(**file_values)
113
+ except ValidationError as error:
114
+ raise ConfigError(_describe(error, path)) from None
115
+ except OSError as error:
116
+ raise ConfigError(f"Cannot read {path}: {error.strerror or error}") from None
@@ -0,0 +1,19 @@
1
+ """Fixed values: Amazon Send to Kindle limits and SMTP defaults."""
2
+
3
+ from typing import Final
4
+
5
+ # Formats accepted by Send to Kindle by email (verified 2026-10-05). MOBI is no longer accepted.
6
+ ALLOWED_EXTENSIONS: Final[frozenset[str]] = frozenset(
7
+ {
8
+ ".doc", ".docx", ".html", ".htm", ".rtf", ".txt",
9
+ ".jpeg", ".jpg", ".gif", ".png", ".bmp", ".pdf", ".epub",
10
+ }
11
+ ) # fmt: skip
12
+
13
+ # Amazon allows 50 MB per email; decimal megabytes is the conservative reading.
14
+ MAX_EMAIL_SIZE_BYTES: Final[int] = 50_000_000
15
+
16
+ SMTP_TIMEOUT_SECONDS: Final[float] = 30.0
17
+
18
+ GMAIL_SMTP_HOST: Final[str] = "smtp.gmail.com"
19
+ GMAIL_APP_PASSWORDS_URL: Final[str] = "https://myaccount.google.com/apppasswords"
@@ -0,0 +1,129 @@
1
+ """Readiness checks for `s2k doctor` and the end of `s2k init`. Never sends email."""
2
+
3
+ import os
4
+ import platform
5
+ import stat
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from send_2_kindle import installed_version
13
+ from send_2_kindle.config import REQUIRED_VARIABLES, config_file_path, load_settings
14
+ from send_2_kindle.errors import ConfigError, SmtpAuthError, SmtpConnectionError
15
+ from send_2_kindle.mailer import KindleMailer
16
+
17
+ AMAZON_NOTE = (
18
+ "Amazon cannot be checked without sending. To confirm end to end: s2k send <small-file.txt>"
19
+ )
20
+
21
+
22
+ class Status(StrEnum):
23
+ OK = "✓"
24
+ WARN = "⚠"
25
+ FAIL = "✗"
26
+ SKIP = "-"
27
+ INFO = "ℹ" # noqa: RUF001 - intentional "information" symbol, not the letter i
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CheckResult:
32
+ status: Status
33
+ message: str
34
+
35
+
36
+ _COLORS: dict[Status, str] = {
37
+ Status.OK: typer.colors.GREEN,
38
+ Status.WARN: typer.colors.YELLOW,
39
+ Status.FAIL: typer.colors.RED,
40
+ }
41
+
42
+
43
+ def _display(path: Path) -> str:
44
+ home = Path.home()
45
+ return f"~/{path.relative_to(home)}" if path.is_relative_to(home) else str(path)
46
+
47
+
48
+ def _skipped(*names: str) -> list[CheckResult]:
49
+ return [CheckResult(Status.SKIP, f"{name} (not checked)") for name in names]
50
+
51
+
52
+ def _config_source() -> list[CheckResult]:
53
+ path = config_file_path()
54
+ if path.is_file():
55
+ results = [CheckResult(Status.OK, f"Config file: {_display(path)}")]
56
+ if os.name == "posix" and stat.S_IMODE(path.stat().st_mode) & 0o077:
57
+ results.append(
58
+ CheckResult(
59
+ Status.WARN,
60
+ f"Config file is readable by other users → chmod 600 {_display(path)}",
61
+ )
62
+ )
63
+ return results
64
+ if all(os.environ.get(name) for name in REQUIRED_VARIABLES):
65
+ return [
66
+ CheckResult(Status.OK, f"Using environment variables (no file at {_display(path)})")
67
+ ]
68
+ return [
69
+ CheckResult(
70
+ Status.FAIL,
71
+ f"No configuration found at {_display(path)} → run `s2k init` in your terminal",
72
+ )
73
+ ]
74
+
75
+
76
+ def run_checks() -> list[CheckResult]:
77
+ """Run every readiness check in order; later checks are skipped when one fails."""
78
+ results = [
79
+ CheckResult(Status.OK, f"s2k {installed_version()} (Python {platform.python_version()})")
80
+ ]
81
+ amazon = CheckResult(Status.INFO, AMAZON_NOTE)
82
+
83
+ results.extend(_config_source())
84
+ if any(result.status is Status.FAIL for result in results):
85
+ return [*results, *_skipped("Settings", "SMTP connection", "SMTP login"), amazon]
86
+
87
+ try:
88
+ settings = load_settings()
89
+ except ConfigError as error:
90
+ results.append(CheckResult(Status.FAIL, str(error)))
91
+ return [*results, *_skipped("SMTP connection", "SMTP login"), amazon]
92
+ results.append(
93
+ CheckResult(
94
+ Status.OK,
95
+ f"Settings valid (sender {settings.sender_email} → kindle {settings.kindle_email})",
96
+ )
97
+ )
98
+
99
+ connected = CheckResult(
100
+ Status.OK,
101
+ f"Connected to {settings.smtp_host}:{settings.smtp_port} "
102
+ f"({settings.smtp_security.upper()})",
103
+ )
104
+ try:
105
+ with KindleMailer(settings):
106
+ pass
107
+ except SmtpConnectionError as error:
108
+ results.append(CheckResult(Status.FAIL, str(error)))
109
+ results.extend(_skipped("SMTP login"))
110
+ except SmtpAuthError as error:
111
+ results.extend([connected, CheckResult(Status.FAIL, str(error))])
112
+ else:
113
+ results.extend(
114
+ [connected, CheckResult(Status.OK, f"Logged in as {settings.login_username}")]
115
+ )
116
+ results.append(amazon)
117
+ return results
118
+
119
+
120
+ def report(results: list[CheckResult]) -> int:
121
+ """Print the results and return the exit code: 0 when nothing failed, else 1."""
122
+ for result in results:
123
+ typer.secho(f"{result.status} {result.message}", fg=_COLORS.get(result.status))
124
+ failures = sum(result.status is Status.FAIL for result in results)
125
+ if failures:
126
+ typer.secho(f"Not ready: {failures} problem(s).", fg=typer.colors.RED)
127
+ return 1
128
+ typer.secho("Ready.", fg=typer.colors.GREEN)
129
+ return 0
@@ -0,0 +1,25 @@
1
+ """Exceptions raised by s2k. Messages are shown to the user as-is."""
2
+
3
+
4
+ class S2KError(Exception):
5
+ """Base class for every expected s2k error."""
6
+
7
+
8
+ class ConfigError(S2KError):
9
+ """The configuration (.env or environment variables) is missing or invalid."""
10
+
11
+
12
+ class FileValidationError(S2KError):
13
+ """A file cannot be sent (missing, unsupported, empty, unreadable or too large)."""
14
+
15
+
16
+ class SmtpConnectionError(S2KError):
17
+ """The SMTP server cannot be reached or the connection was lost."""
18
+
19
+
20
+ class SmtpAuthError(S2KError):
21
+ """The SMTP server rejected the credentials."""
22
+
23
+
24
+ class SendError(S2KError):
25
+ """The SMTP server rejected one specific message."""
@@ -0,0 +1,162 @@
1
+ """Build Send to Kindle emails and send them over one SMTP session."""
2
+
3
+ import mimetypes
4
+ import smtplib
5
+ import ssl
6
+ from email.message import EmailMessage
7
+ from pathlib import Path
8
+ from types import TracebackType
9
+ from typing import Self
10
+
11
+ from send_2_kindle import constants
12
+ from send_2_kindle.config import Settings
13
+ from send_2_kindle.errors import SendError, SmtpAuthError, SmtpConnectionError
14
+
15
+
16
+ def build_message(settings: Settings, path: Path) -> EmailMessage:
17
+ """Return an email to the Kindle address with ``path`` as its only attachment."""
18
+ message = EmailMessage()
19
+ message["From"] = settings.sender_email
20
+ message["To"] = settings.kindle_email
21
+ message["Subject"] = path.name
22
+ message.set_content("")
23
+
24
+ mime_type, _ = mimetypes.guess_type(path.name)
25
+ maintype, subtype = (mime_type or "application/octet-stream").split("/", 1)
26
+ message.add_attachment(
27
+ path.read_bytes(), maintype=maintype, subtype=subtype, filename=path.name
28
+ )
29
+ return message
30
+
31
+
32
+ def _text(value: bytes | str) -> str:
33
+ return value.decode(errors="replace") if isinstance(value, bytes) else value
34
+
35
+
36
+ def _auth_failure_message(settings: Settings) -> str:
37
+ message = f"authentication failed for {settings.login_username} on {settings.smtp_host}"
38
+ if settings.smtp_host == constants.GMAIL_SMTP_HOST:
39
+ message += (
40
+ ". Gmail requires an app password, not your account password: "
41
+ f"{constants.GMAIL_APP_PASSWORDS_URL}"
42
+ )
43
+ return message
44
+
45
+
46
+ def _close_quietly(smtp: smtplib.SMTP | None) -> None:
47
+ if smtp is not None:
48
+ smtp.close()
49
+
50
+
51
+ def _login(smtp: smtplib.SMTP, settings: Settings) -> None:
52
+ """Log in, reporting any other SMTP refusal of the login as an auth error."""
53
+ try:
54
+ smtp.login(settings.login_username, settings.smtp_password.get_secret_value())
55
+ except (
56
+ smtplib.SMTPAuthenticationError,
57
+ smtplib.SMTPNotSupportedError,
58
+ smtplib.SMTPServerDisconnected,
59
+ ):
60
+ raise
61
+ except smtplib.SMTPException as error:
62
+ _close_quietly(smtp)
63
+ raise SmtpAuthError(
64
+ f"login failed for {settings.login_username} on {settings.smtp_host}: {error}"
65
+ ) from error
66
+
67
+
68
+ class KindleMailer:
69
+ """One authenticated SMTP session that sends one email per file.
70
+
71
+ Use as a context manager: ``with KindleMailer(settings) as mailer: mailer.send(path)``.
72
+ """
73
+
74
+ def __init__(self, settings: Settings) -> None:
75
+ self._settings = settings
76
+ self._smtp: smtplib.SMTP | None = None
77
+
78
+ def __enter__(self) -> Self:
79
+ settings = self._settings
80
+ smtp: smtplib.SMTP | None = None
81
+ try:
82
+ if settings.smtp_security == "ssl":
83
+ smtp = smtplib.SMTP_SSL(
84
+ settings.smtp_host,
85
+ settings.smtp_port,
86
+ timeout=constants.SMTP_TIMEOUT_SECONDS,
87
+ context=ssl.create_default_context(),
88
+ )
89
+ else:
90
+ smtp = smtplib.SMTP(
91
+ settings.smtp_host,
92
+ settings.smtp_port,
93
+ timeout=constants.SMTP_TIMEOUT_SECONDS,
94
+ )
95
+ smtp.starttls(context=ssl.create_default_context())
96
+ _login(smtp, settings)
97
+ except smtplib.SMTPAuthenticationError as error:
98
+ _close_quietly(smtp)
99
+ raise SmtpAuthError(_auth_failure_message(settings)) from error
100
+ except UnicodeEncodeError as error: # smtplib sends credentials as ASCII
101
+ _close_quietly(smtp)
102
+ raise SmtpAuthError(
103
+ "SMTP username and password must contain only ASCII characters"
104
+ ) from error
105
+ except smtplib.SMTPNotSupportedError as error: # e.g. no STARTTLS or no AUTH
106
+ _close_quietly(smtp)
107
+ raise SmtpConnectionError(
108
+ f"{settings.smtp_host}:{settings.smtp_port} does not support a required feature "
109
+ f"(check S2K_SMTP_SECURITY and S2K_SMTP_PORT): {error}"
110
+ ) from error
111
+ except OSError as error: # smtplib.SMTPException, timeouts and ssl.SSLError are OSErrors
112
+ _close_quietly(smtp)
113
+ raise SmtpConnectionError(
114
+ f"could not connect to {settings.smtp_host}:{settings.smtp_port}: {error}"
115
+ ) from error
116
+ self._smtp = smtp
117
+ return self
118
+
119
+ def __exit__(
120
+ self,
121
+ exc_type: type[BaseException] | None,
122
+ exc: BaseException | None,
123
+ traceback: TracebackType | None,
124
+ ) -> None:
125
+ smtp, self._smtp = self._smtp, None
126
+ if smtp is None:
127
+ return
128
+ try:
129
+ smtp.quit()
130
+ except OSError:
131
+ smtp.close()
132
+
133
+ def send(self, path: Path) -> None:
134
+ """Send ``path`` as one email.
135
+
136
+ Raises SendError when only this message failed, and SmtpConnectionError when the
137
+ session is lost.
138
+ """
139
+ if self._smtp is None:
140
+ raise RuntimeError("KindleMailer.send() must be called inside a 'with' block")
141
+ try:
142
+ message = build_message(self._settings, path)
143
+ except OSError as error:
144
+ raise SendError(f"could not read file: {error}") from error
145
+ try:
146
+ self._smtp.send_message(message)
147
+ except smtplib.SMTPServerDisconnected as error:
148
+ raise SmtpConnectionError(f"connection lost: {error}") from error
149
+ except smtplib.SMTPRecipientsRefused as error:
150
+ details = "; ".join(
151
+ f"{address}: {code} {_text(reply)}"
152
+ for address, (code, reply) in error.recipients.items()
153
+ )
154
+ raise SendError(f"recipient refused: {details}") from error
155
+ except smtplib.SMTPResponseException as error:
156
+ raise SendError(
157
+ f"server rejected the message: {error.smtp_code} {_text(error.smtp_error)}"
158
+ ) from error
159
+ except smtplib.SMTPException as error:
160
+ raise SendError(f"server rejected the message: {error}") from error
161
+ except OSError as error: # network errors and timeouts after login
162
+ raise SmtpConnectionError(f"connection lost: {error}") from error
send_2_kindle/main.py ADDED
@@ -0,0 +1,121 @@
1
+ """Command-line entry point: ``s2k FILE...``."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from send_2_kindle import installed_version
10
+ from send_2_kindle.config import load_settings
11
+ from send_2_kindle.doctor import report, run_checks
12
+ from send_2_kindle.errors import (
13
+ ConfigError,
14
+ FileValidationError,
15
+ SendError,
16
+ SmtpAuthError,
17
+ SmtpConnectionError,
18
+ )
19
+ from send_2_kindle.mailer import KindleMailer
20
+ from send_2_kindle.validation import validate_file
21
+ from send_2_kindle.wizard import run_wizard
22
+
23
+ AMAZON_NOTE = (
24
+ "Note: Amazon may still reject a sent file (e.g. sender not approved); it will email you if so."
25
+ )
26
+
27
+ # Never show local variables in tracebacks: they could include the SMTP password.
28
+ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False, no_args_is_help=True)
29
+
30
+
31
+ def _print_version(value: bool) -> None:
32
+ if value:
33
+ typer.echo(f"s2k {installed_version()}")
34
+ raise typer.Exit()
35
+
36
+
37
+ @app.callback()
38
+ def cli(
39
+ version: Annotated[
40
+ bool,
41
+ typer.Option(
42
+ "--version", callback=_print_version, is_eager=True, help="Show the version and exit."
43
+ ),
44
+ ] = False,
45
+ ) -> None:
46
+ """Send local files to your Kindle through the Send to Kindle email service."""
47
+
48
+
49
+ @dataclass
50
+ class Outcome:
51
+ path: Path
52
+ sent: bool = False
53
+ reason: str = "not sent"
54
+
55
+
56
+ @app.command()
57
+ def send(
58
+ files: Annotated[
59
+ list[Path],
60
+ typer.Argument(help="Files to send (pdf, epub, docx, txt, ...).", show_default=False),
61
+ ],
62
+ ) -> None:
63
+ """Send files to your Kindle, one email per file."""
64
+ try:
65
+ settings = load_settings()
66
+ except ConfigError as error:
67
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
68
+ raise typer.Exit(code=2) from None
69
+
70
+ outcomes = [Outcome(path) for path in files]
71
+ pending: list[Outcome] = []
72
+ for outcome in outcomes:
73
+ try:
74
+ validate_file(outcome.path)
75
+ except FileValidationError as error:
76
+ outcome.reason = str(error)
77
+ else:
78
+ pending.append(outcome)
79
+
80
+ if pending:
81
+ try:
82
+ with KindleMailer(settings) as mailer:
83
+ for outcome in pending:
84
+ try:
85
+ mailer.send(outcome.path)
86
+ except SendError as error:
87
+ outcome.reason = str(error)
88
+ else:
89
+ outcome.sent = True
90
+ except (SmtpConnectionError, SmtpAuthError) as error:
91
+ typer.secho(f"Error: {error}", fg=typer.colors.RED, err=True)
92
+
93
+ _print_report(outcomes)
94
+ if not all(outcome.sent for outcome in outcomes):
95
+ raise typer.Exit(code=1)
96
+
97
+
98
+ @app.command()
99
+ def doctor() -> None:
100
+ """Check that s2k is configured and can log in to your SMTP server (sends nothing)."""
101
+ raise typer.Exit(code=report(run_checks()))
102
+
103
+
104
+ @app.command()
105
+ def init() -> None:
106
+ """Interactive setup that writes your config file. Run it in your own terminal."""
107
+ raise typer.Exit(code=run_wizard())
108
+
109
+
110
+ def _print_report(outcomes: list[Outcome]) -> None:
111
+ width = max(len(str(outcome.path)) for outcome in outcomes)
112
+ for outcome in outcomes:
113
+ name = str(outcome.path).ljust(width)
114
+ if outcome.sent:
115
+ typer.secho(f"✓ {name} sent", fg=typer.colors.GREEN)
116
+ else:
117
+ typer.secho(f"✗ {name} {outcome.reason}", fg=typer.colors.RED)
118
+ sent = sum(outcome.sent for outcome in outcomes)
119
+ typer.echo(f"{sent} sent, {len(outcomes) - sent} failed")
120
+ if sent:
121
+ typer.echo(AMAZON_NOTE)
@@ -0,0 +1,41 @@
1
+ """Local checks that decide whether a file can be sent to Kindle."""
2
+
3
+ import os
4
+ import stat
5
+ from pathlib import Path
6
+
7
+ from send_2_kindle import constants
8
+ from send_2_kindle.errors import FileValidationError
9
+
10
+
11
+ def _megabytes(size: int) -> float:
12
+ return size / 1_000_000
13
+
14
+
15
+ def validate_file(path: Path) -> None:
16
+ """Raise FileValidationError with a user-facing reason if ``path`` cannot be sent."""
17
+ # stat() tells "missing" apart from "can't reach it" (Path.exists() reports both as False).
18
+ try:
19
+ info = path.stat()
20
+ except FileNotFoundError:
21
+ raise FileValidationError("file not found") from None
22
+ except PermissionError:
23
+ raise FileValidationError("file is not readable") from None
24
+ if not stat.S_ISREG(info.st_mode):
25
+ raise FileValidationError("not a regular file")
26
+
27
+ extension = path.suffix.lower()
28
+ if extension not in constants.ALLOWED_EXTENSIONS:
29
+ allowed = ", ".join(sorted(constants.ALLOWED_EXTENSIONS))
30
+ raise FileValidationError(
31
+ f"unsupported extension '{extension or '(none)'}'; allowed: {allowed}"
32
+ )
33
+
34
+ size = info.st_size
35
+ if size == 0:
36
+ raise FileValidationError("file is empty")
37
+ if not os.access(path, os.R_OK):
38
+ raise FileValidationError("file is not readable")
39
+ if size > constants.MAX_EMAIL_SIZE_BYTES:
40
+ limit = _megabytes(constants.MAX_EMAIL_SIZE_BYTES)
41
+ raise FileValidationError(f"file exceeds {limit:g} MB ({_megabytes(size):.1f} MB)")
@@ -0,0 +1,139 @@
1
+ """`s2k init`: interactive wizard that writes the user's config file."""
2
+
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+ from pydantic import EmailStr, TypeAdapter, ValidationError
11
+
12
+ from send_2_kindle import constants
13
+ from send_2_kindle.config import (
14
+ SmtpHost,
15
+ SmtpPort,
16
+ SmtpSecurity,
17
+ config_file_path,
18
+ read_config_file,
19
+ )
20
+ from send_2_kindle.doctor import report, run_checks
21
+
22
+ INTRO = """\
23
+ s2k init: configure Send to Kindle by email.
24
+
25
+ You will need:
26
+ - Your Send to Kindle address: Amazon > Manage Your Content and Devices > Preferences >
27
+ Personal Document Settings.
28
+ - The email you send from must be on the "Approved Personal Document E-mail List" (same page).
29
+ - Gmail: an app password, not your account password: {url}
30
+ """
31
+ HEADER = "# s2k configuration, written by `s2k init`. Keep it private (chmod 600).\n"
32
+
33
+ _EMAIL: TypeAdapter[Any] = TypeAdapter(EmailStr)
34
+ _HOST: TypeAdapter[Any] = TypeAdapter(SmtpHost)
35
+ _PORT: TypeAdapter[Any] = TypeAdapter(SmtpPort)
36
+ _SECURITY: TypeAdapter[Any] = TypeAdapter(SmtpSecurity)
37
+
38
+
39
+ def _is_interactive() -> bool:
40
+ return sys.stdin.isatty()
41
+
42
+
43
+ def _quote(value: str) -> str:
44
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
45
+ return f'"{escaped}"'
46
+
47
+
48
+ def write_config(path: Path, values: dict[str, str]) -> None:
49
+ """Atomically write ``values`` as KEY="value" lines to ``path`` (mode 600, new dirs 700)."""
50
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
51
+ content = HEADER + "".join(f"{key}={_quote(value)}\n" for key, value in values.items())
52
+ fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=".config.env.")
53
+ try:
54
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
55
+ handle.write(content)
56
+ os.chmod(temp_name, 0o600)
57
+ os.replace(temp_name, path)
58
+ except BaseException:
59
+ Path(temp_name).unlink(missing_ok=True)
60
+ raise
61
+
62
+
63
+ def _read_existing(path: Path) -> dict[str, str]:
64
+ try:
65
+ return read_config_file(path)
66
+ except OSError:
67
+ return {}
68
+
69
+
70
+ def _ask(text: str, adapter: TypeAdapter[Any], default: str | None) -> str:
71
+ while True:
72
+ answer = str(typer.prompt(text, default=default, type=str)).strip()
73
+ try:
74
+ adapter.validate_python(answer)
75
+ except ValidationError as error:
76
+ typer.secho(f" {error.errors()[0]['msg']}", fg=typer.colors.YELLOW)
77
+ else:
78
+ return answer
79
+
80
+
81
+ def _ask_password(has_current: bool) -> str | None:
82
+ """Return the new password, or None to keep the current one."""
83
+ if has_current:
84
+ answer = typer.prompt(
85
+ "SMTP password (empty keeps the current one)",
86
+ default="",
87
+ hide_input=True,
88
+ show_default=False,
89
+ )
90
+ return str(answer) or None
91
+ return str(typer.prompt("SMTP password", hide_input=True))
92
+
93
+
94
+ def _collect(current: dict[str, str]) -> dict[str, str]:
95
+ values = {
96
+ "S2K_KINDLE_EMAIL": _ask("Send to Kindle address", _EMAIL, current.get("S2K_KINDLE_EMAIL")),
97
+ "S2K_SENDER_EMAIL": _ask("Email you send from", _EMAIL, current.get("S2K_SENDER_EMAIL")),
98
+ "S2K_SMTP_HOST": _ask(
99
+ "SMTP server", _HOST, current.get("S2K_SMTP_HOST", constants.GMAIL_SMTP_HOST)
100
+ ),
101
+ "S2K_SMTP_PORT": _ask("SMTP port", _PORT, current.get("S2K_SMTP_PORT", "587")),
102
+ "S2K_SMTP_SECURITY": _ask(
103
+ "Security (starttls/ssl)", _SECURITY, current.get("S2K_SMTP_SECURITY", "starttls")
104
+ ),
105
+ }
106
+ password = _ask_password(bool(current.get("S2K_SMTP_PASSWORD")))
107
+ values["S2K_SMTP_PASSWORD"] = password if password is not None else current["S2K_SMTP_PASSWORD"]
108
+ if current.get("S2K_SMTP_USERNAME"):
109
+ values["S2K_SMTP_USERNAME"] = current["S2K_SMTP_USERNAME"]
110
+ return values
111
+
112
+
113
+ def run_wizard() -> int:
114
+ """Run the interactive setup and return the process exit code."""
115
+ if not _is_interactive():
116
+ typer.secho(
117
+ "s2k init is interactive; run it in your own terminal.", fg=typer.colors.RED, err=True
118
+ )
119
+ return 2
120
+ path = config_file_path()
121
+ typer.echo(INTRO.format(url=constants.GMAIL_APP_PASSWORDS_URL))
122
+ try:
123
+ values = _collect(_read_existing(path))
124
+ except typer.Abort:
125
+ typer.secho("Aborted; configuration not changed.", fg=typer.colors.RED, err=True)
126
+ return 1
127
+ try:
128
+ write_config(path, values)
129
+ except OSError as error:
130
+ typer.secho(
131
+ f"Cannot write {path}: {error.strerror or error}", fg=typer.colors.RED, err=True
132
+ )
133
+ return 2
134
+ typer.echo(f"Saved {path} (permissions 600)")
135
+ try:
136
+ run_now = typer.confirm("Run checks now?", default=True)
137
+ except typer.Abort:
138
+ return 0
139
+ return report(run_checks()) if run_now else 0