s2k-cli 0.1.0__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.
- s2k_cli-0.1.0/LICENSE +21 -0
- s2k_cli-0.1.0/PKG-INFO +104 -0
- s2k_cli-0.1.0/README.md +77 -0
- s2k_cli-0.1.0/pyproject.toml +67 -0
- s2k_cli-0.1.0/src/send_2_kindle/__init__.py +10 -0
- s2k_cli-0.1.0/src/send_2_kindle/__main__.py +3 -0
- s2k_cli-0.1.0/src/send_2_kindle/config.py +116 -0
- s2k_cli-0.1.0/src/send_2_kindle/constants.py +19 -0
- s2k_cli-0.1.0/src/send_2_kindle/doctor.py +129 -0
- s2k_cli-0.1.0/src/send_2_kindle/errors.py +25 -0
- s2k_cli-0.1.0/src/send_2_kindle/mailer.py +162 -0
- s2k_cli-0.1.0/src/send_2_kindle/main.py +121 -0
- s2k_cli-0.1.0/src/send_2_kindle/validation.py +41 -0
- s2k_cli-0.1.0/src/send_2_kindle/wizard.py +139 -0
s2k_cli-0.1.0/LICENSE
ADDED
|
@@ -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.
|
s2k_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
|
s2k_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# s2k — send files to your Kindle
|
|
2
|
+
|
|
3
|
+
`s2k` emails documents to your Kindle through Amazon's Send to Kindle service, one email per file.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
s2k send book.epub paper.pdf notes.docx
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Supported: `.pdf .epub .doc .docx .txt .rtf .html .htm .jpg .jpeg .png .gif .bmp`, up to 50 MB
|
|
10
|
+
each (your email provider may allow less: Gmail rejects files above roughly 18 MB).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Requires Python 3.13+.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pipx install s2k-cli # or: uv tool install s2k-cli
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Set up
|
|
21
|
+
|
|
22
|
+
1. **In Amazon** (*Manage Your Content and Devices → Preferences → Personal Document Settings*):
|
|
23
|
+
copy your `@kindle.com` address and add the email you will send from to the *Approved Personal
|
|
24
|
+
Document E-mail List*.
|
|
25
|
+
2. **Gmail users:** create an app password at <https://myaccount.google.com/apppasswords>
|
|
26
|
+
(requires 2-Step Verification). Your normal Gmail password will not work.
|
|
27
|
+
3. Run the wizard in your terminal — it asks for the values, hides the password, saves them to
|
|
28
|
+
`~/.config/s2k/config.env` with private permissions and offers to test the login:
|
|
29
|
+
```bash
|
|
30
|
+
s2k init
|
|
31
|
+
```
|
|
32
|
+
4. Check that everything is ready at any time (sends nothing):
|
|
33
|
+
```bash
|
|
34
|
+
s2k doctor
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
s2k send FILE... # send files, one email each
|
|
41
|
+
s2k doctor # check configuration and SMTP login
|
|
42
|
+
s2k init # create or update the configuration
|
|
43
|
+
s2k --version
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Exit codes: `0` success · `1` something failed (see the report) · `2` invalid configuration or usage.
|
|
47
|
+
|
|
48
|
+
"Sent" means your email provider accepted the message. If Amazon rejects it (for example, the
|
|
49
|
+
sender is not approved), Amazon emails you.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
Read from, highest priority first: environment variables → the file in `S2K_CONFIG_FILE` →
|
|
54
|
+
`$XDG_CONFIG_HOME/s2k/config.env` → `~/.config/s2k/config.env`.
|
|
55
|
+
|
|
56
|
+
| Variable | Required | Default |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `S2K_KINDLE_EMAIL` | yes | — |
|
|
59
|
+
| `S2K_SENDER_EMAIL` | yes | — |
|
|
60
|
+
| `S2K_SMTP_PASSWORD` | yes | — |
|
|
61
|
+
| `S2K_SMTP_HOST` | no | `smtp.gmail.com` |
|
|
62
|
+
| `S2K_SMTP_PORT` | no | `587` |
|
|
63
|
+
| `S2K_SMTP_SECURITY` | no | `starttls` (`ssl` for port 465) |
|
|
64
|
+
| `S2K_SMTP_USERNAME` | no | the sender address |
|
|
65
|
+
|
|
66
|
+
## Troubleshooting
|
|
67
|
+
|
|
68
|
+
| `s2k doctor` says | Do this |
|
|
69
|
+
|---|---|
|
|
70
|
+
| No configuration found | Run `s2k init` |
|
|
71
|
+
| authentication failed … app password | Create a Gmail app password and run `s2k init` again |
|
|
72
|
+
| could not connect | Check `S2K_SMTP_HOST`, `S2K_SMTP_PORT` and `S2K_SMTP_SECURITY` |
|
|
73
|
+
| Config file is readable by other users | `chmod 600 ~/.config/s2k/config.env` |
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "s2k-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Send local files to your Kindle from the command line."
|
|
5
|
+
authors = [{ name = "Marco Fura", email = "mjfura27@gmail.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = ["kindle", "send-to-kindle", "ebook", "email", "cli"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: End Users/Desktop",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Communications :: Email",
|
|
18
|
+
]
|
|
19
|
+
requires-python = ">=3.13,<4.0"
|
|
20
|
+
dependencies = [
|
|
21
|
+
"typer (>=0.27,<1.0)",
|
|
22
|
+
"pydantic (>=2.13,<3.0)",
|
|
23
|
+
"pydantic-settings (>=2.15,<3.0)",
|
|
24
|
+
"email-validator (>=2.3,<3.0)",
|
|
25
|
+
"python-dotenv (>=1.2,<2.0)",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
s2k = "send_2_kindle.main:app"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/mjfura/send-2-kindle"
|
|
33
|
+
Repository = "https://github.com/mjfura/send-2-kindle"
|
|
34
|
+
Issues = "https://github.com/mjfura/send-2-kindle/issues"
|
|
35
|
+
|
|
36
|
+
[tool.poetry]
|
|
37
|
+
packages = [{ include = "send_2_kindle", from = "src" }]
|
|
38
|
+
|
|
39
|
+
[tool.poetry.group.dev.dependencies]
|
|
40
|
+
pytest = "^9.1"
|
|
41
|
+
ruff = "^0.16"
|
|
42
|
+
mypy = "^2.4"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
46
|
+
build-backend = "poetry.core.masonry.api"
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py313"
|
|
51
|
+
src = ["src", "tests"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "W", "F", "I", "B", "UP", "SIM", "RUF", "S", "PT"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
"tests/**" = ["S101", "S105", "S106"]
|
|
58
|
+
|
|
59
|
+
[tool.mypy]
|
|
60
|
+
python_version = "3.13"
|
|
61
|
+
strict = true
|
|
62
|
+
mypy_path = "src"
|
|
63
|
+
plugins = ["pydantic.mypy"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
addopts = "-ra --strict-markers"
|
|
@@ -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,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
|
|
@@ -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
|