s2k-cli 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: s2k-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Send local files to your Kindle from the command line.
5
5
  License: MIT
6
6
  Keywords: kindle,send-to-kindle,ebook,email,cli
@@ -33,7 +33,7 @@ s2k send book.epub paper.pdf notes.docx
33
33
  ```
34
34
 
35
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).
36
+ each (your email provider may allow less: Gmail about 18 MB, iCloud Mail about 14 MB).
37
37
 
38
38
  ## Install
39
39
 
@@ -48,8 +48,13 @@ pipx install s2k-cli # or: uv tool install s2k-cli
48
48
  1. **In Amazon** (*Manage Your Content and Devices → Preferences → Personal Document Settings*):
49
49
  copy your `@kindle.com` address and add the email you will send from to the *Approved Personal
50
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.
51
+ 2. **Create the password your provider requires** — your normal password will not work:
52
+
53
+ | Provider | Password | Server (filled in by `s2k init`) |
54
+ |---|---|---|
55
+ | Gmail | [App password](https://myaccount.google.com/apppasswords) (needs 2-Step Verification) | `smtp.gmail.com`, 587, STARTTLS |
56
+ | iCloud Mail | App-specific password at [account.apple.com](https://account.apple.com) → Sign-In and Security (needs two-factor authentication); send from your full `@icloud.com` address | `smtp.mail.me.com`, 587, STARTTLS |
57
+ | Other | Your provider's SMTP password | you type it |
53
58
  3. Run the wizard in your terminal — it asks for the values, hides the password, saves them to
54
59
  `~/.config/s2k/config.env` with private permissions and offers to test the login:
55
60
  ```bash
@@ -71,6 +76,9 @@ s2k --version
71
76
 
72
77
  Exit codes: `0` success · `1` something failed (see the report) · `2` invalid configuration or usage.
73
78
 
79
+ Files in iCloud Drive that are not downloaded yet ("Optimize Mac Storage") are downloaded
80
+ automatically when sent, so you need a connection.
81
+
74
82
  "Sent" means your email provider accepted the message. If Amazon rejects it (for example, the
75
83
  sender is not approved), Amazon emails you.
76
84
 
@@ -7,7 +7,7 @@ s2k send book.epub paper.pdf notes.docx
7
7
  ```
8
8
 
9
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).
10
+ each (your email provider may allow less: Gmail about 18 MB, iCloud Mail about 14 MB).
11
11
 
12
12
  ## Install
13
13
 
@@ -22,8 +22,13 @@ pipx install s2k-cli # or: uv tool install s2k-cli
22
22
  1. **In Amazon** (*Manage Your Content and Devices → Preferences → Personal Document Settings*):
23
23
  copy your `@kindle.com` address and add the email you will send from to the *Approved Personal
24
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.
25
+ 2. **Create the password your provider requires** — your normal password will not work:
26
+
27
+ | Provider | Password | Server (filled in by `s2k init`) |
28
+ |---|---|---|
29
+ | Gmail | [App password](https://myaccount.google.com/apppasswords) (needs 2-Step Verification) | `smtp.gmail.com`, 587, STARTTLS |
30
+ | iCloud Mail | App-specific password at [account.apple.com](https://account.apple.com) → Sign-In and Security (needs two-factor authentication); send from your full `@icloud.com` address | `smtp.mail.me.com`, 587, STARTTLS |
31
+ | Other | Your provider's SMTP password | you type it |
27
32
  3. Run the wizard in your terminal — it asks for the values, hides the password, saves them to
28
33
  `~/.config/s2k/config.env` with private permissions and offers to test the login:
29
34
  ```bash
@@ -45,6 +50,9 @@ s2k --version
45
50
 
46
51
  Exit codes: `0` success · `1` something failed (see the report) · `2` invalid configuration or usage.
47
52
 
53
+ Files in iCloud Drive that are not downloaded yet ("Optimize Mac Storage") are downloaded
54
+ automatically when sent, so you need a connection.
55
+
48
56
  "Sent" means your email provider accepted the message. If Amazon rejects it (for example, the
49
57
  sender is not approved), Amazon emails you.
50
58
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "s2k-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Send local files to your Kindle from the command line."
5
5
  authors = [{ name = "Marco Fura", email = "mjfura27@gmail.com" }]
6
6
  readme = "README.md"
@@ -8,8 +8,8 @@ from dotenv import dotenv_values
8
8
  from pydantic import EmailStr, Field, SecretStr, ValidationError, field_validator
9
9
  from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
10
10
 
11
- from send_2_kindle import constants
12
11
  from send_2_kindle.errors import ConfigError
12
+ from send_2_kindle.providers import GMAIL
13
13
 
14
14
  ENV_PREFIX: Final[str] = "S2K_"
15
15
  CONFIG_FILE_VARIABLE: Final[str] = "S2K_CONFIG_FILE"
@@ -49,7 +49,7 @@ class Settings(BaseSettings):
49
49
  kindle_email: EmailStr
50
50
  sender_email: EmailStr
51
51
  smtp_password: SecretStr
52
- smtp_host: SmtpHost = constants.GMAIL_SMTP_HOST
52
+ smtp_host: SmtpHost = GMAIL.host
53
53
  smtp_port: SmtpPort = 587
54
54
  smtp_security: SmtpSecurity = "starttls"
55
55
  smtp_username: str | None = None
@@ -14,6 +14,3 @@ ALLOWED_EXTENSIONS: Final[frozenset[str]] = frozenset(
14
14
  MAX_EMAIL_SIZE_BYTES: Final[int] = 50_000_000
15
15
 
16
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"
@@ -11,6 +11,8 @@ from typing import Self
11
11
  from send_2_kindle import constants
12
12
  from send_2_kindle.config import Settings
13
13
  from send_2_kindle.errors import SendError, SmtpAuthError, SmtpConnectionError
14
+ from send_2_kindle.providers import provider_for_host
15
+ from send_2_kindle.validation import is_not_downloaded
14
16
 
15
17
 
16
18
  def build_message(settings: Settings, path: Path) -> EmailMessage:
@@ -35,10 +37,11 @@ def _text(value: bytes | str) -> str:
35
37
 
36
38
  def _auth_failure_message(settings: Settings) -> str:
37
39
  message = f"authentication failed for {settings.login_username} on {settings.smtp_host}"
38
- if settings.smtp_host == constants.GMAIL_SMTP_HOST:
40
+ provider = provider_for_host(settings.smtp_host)
41
+ if provider is not None:
39
42
  message += (
40
- ". Gmail requires an app password, not your account password: "
41
- f"{constants.GMAIL_APP_PASSWORDS_URL}"
43
+ f". {provider.name} requires an {provider.password_name}, not your account password: "
44
+ f"{provider.password_url}"
42
45
  )
43
46
  return message
44
47
 
@@ -150,6 +153,10 @@ class KindleMailer:
150
153
  try:
151
154
  message = build_message(self._settings, path)
152
155
  except OSError as error:
156
+ if is_not_downloaded(path):
157
+ raise SendError(
158
+ f"could not download it from iCloud (are you offline?): {error}"
159
+ ) from error
153
160
  raise SendError(f"could not read file: {error}") from error
154
161
  try:
155
162
  self._smtp.send_message(message)
@@ -6,7 +6,7 @@ from typing import Annotated
6
6
 
7
7
  import typer
8
8
 
9
- from send_2_kindle import installed_version
9
+ from send_2_kindle import constants, installed_version
10
10
  from send_2_kindle.config import load_settings
11
11
  from send_2_kindle.doctor import report, run_checks
12
12
  from send_2_kindle.errors import (
@@ -17,7 +17,8 @@ from send_2_kindle.errors import (
17
17
  SmtpConnectionError,
18
18
  )
19
19
  from send_2_kindle.mailer import KindleMailer
20
- from send_2_kindle.validation import validate_file
20
+ from send_2_kindle.providers import provider_for_host
21
+ from send_2_kindle.validation import is_not_downloaded, validate_file
21
22
  from send_2_kindle.wizard import run_wizard
22
23
 
23
24
  AMAZON_NOTE = (
@@ -67,16 +68,34 @@ def send(
67
68
  typer.secho(str(error), fg=typer.colors.RED, err=True)
68
69
  raise typer.Exit(code=2) from None
69
70
 
71
+ provider = provider_for_host(settings.smtp_host)
72
+ max_bytes = min(constants.MAX_EMAIL_SIZE_BYTES, provider.max_file_bytes) if provider else None
73
+ provider_name = provider.name if provider else None
74
+
70
75
  outcomes = [Outcome(path) for path in files]
71
76
  pending: list[Outcome] = []
72
77
  for outcome in outcomes:
73
78
  try:
74
- validate_file(outcome.path)
79
+ validate_file(outcome.path, max_bytes=max_bytes, provider_name=provider_name)
75
80
  except FileValidationError as error:
76
81
  outcome.reason = str(error)
77
82
  else:
78
83
  pending.append(outcome)
79
84
 
85
+ # Download iCloud Drive files before logging in: a long download would leave the SMTP
86
+ # session idle, and a server that drops it would fail every remaining file.
87
+ ready: list[Outcome] = []
88
+ for outcome in pending:
89
+ if is_not_downloaded(outcome.path):
90
+ typer.echo(f'Downloading "{outcome.path.name}" from iCloud…')
91
+ try:
92
+ _fetch_from_icloud(outcome.path)
93
+ except OSError as error:
94
+ outcome.reason = f"could not download it from iCloud (are you offline?): {error}"
95
+ continue
96
+ ready.append(outcome)
97
+ pending = ready
98
+
80
99
  if pending:
81
100
  try:
82
101
  with KindleMailer(settings) as mailer:
@@ -107,6 +126,11 @@ def init() -> None:
107
126
  raise typer.Exit(code=run_wizard())
108
127
 
109
128
 
129
+ def _fetch_from_icloud(path: Path) -> None:
130
+ """Reading a not-downloaded iCloud Drive file makes macOS download it."""
131
+ path.read_bytes()
132
+
133
+
110
134
  def _print_report(outcomes: list[Outcome]) -> None:
111
135
  width = max(len(str(outcome.path)) for outcome in outcomes)
112
136
  for outcome in outcomes:
@@ -0,0 +1,40 @@
1
+ """Known email providers: SMTP settings, attachment limits and where to get their passwords."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Final
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Provider:
9
+ key: str
10
+ name: str
11
+ host: str
12
+ port: int
13
+ security: str
14
+ # Message limits (Gmail 25 MB, iCloud 20 MB) divided by ~1.37 (base64 4/3 and CRLF line
15
+ # wrapping 78/76), rounded down: 18 MB → ~24.6 MB, 14 MB → ~19.2 MB.
16
+ max_file_bytes: int
17
+ password_name: str
18
+ password_url: str
19
+ aliases: tuple[str, ...] = field(default=())
20
+
21
+
22
+ GMAIL: Final = Provider(
23
+ "gmail", "Gmail", "smtp.gmail.com", 587, "starttls", 18_000_000,
24
+ "app password", "https://myaccount.google.com/apppasswords",
25
+ ) # fmt: skip
26
+ ICLOUD: Final = Provider(
27
+ "icloud", "iCloud", "smtp.mail.me.com", 587, "starttls", 14_000_000,
28
+ "app-specific password", "https://account.apple.com", ("smtp.me.com", "smtp.mac.com"),
29
+ ) # fmt: skip
30
+ PROVIDERS: Final[tuple[Provider, ...]] = (GMAIL, ICLOUD)
31
+
32
+
33
+ def provider_for_host(host: str) -> Provider | None:
34
+ """Return the provider whose SMTP server is ``host`` (case and spaces ignored)."""
35
+ normalized = host.strip().lower()
36
+ return next((p for p in PROVIDERS if normalized == p.host or normalized in p.aliases), None)
37
+
38
+
39
+ def provider_for_key(key: str) -> Provider | None:
40
+ return next((provider for provider in PROVIDERS if provider.key == key), None)
@@ -3,16 +3,31 @@
3
3
  import os
4
4
  import stat
5
5
  from pathlib import Path
6
+ from typing import Final
6
7
 
7
8
  from send_2_kindle import constants
8
9
  from send_2_kindle.errors import FileValidationError
9
10
 
11
+ # macOS marks iCloud Drive files whose contents are not on disk yet ("Optimize Mac Storage").
12
+ SF_DATALESS: Final[int] = 0x40000000
13
+
14
+
15
+ def is_not_downloaded(path: Path) -> bool:
16
+ """True for an iCloud Drive file macOS has not downloaded; reading it triggers the download."""
17
+ try:
18
+ flags = getattr(path.stat(), "st_flags", 0)
19
+ except OSError:
20
+ return False
21
+ return bool(flags & SF_DATALESS)
22
+
10
23
 
11
24
  def _megabytes(size: int) -> float:
12
25
  return size / 1_000_000
13
26
 
14
27
 
15
- def validate_file(path: Path) -> None:
28
+ def validate_file(
29
+ path: Path, max_bytes: int | None = None, provider_name: str | None = None
30
+ ) -> None:
16
31
  """Raise FileValidationError with a user-facing reason if ``path`` cannot be sent."""
17
32
  # stat() tells "missing" apart from "can't reach it" (Path.exists() reports both as False).
18
33
  try:
@@ -36,6 +51,8 @@ def validate_file(path: Path) -> None:
36
51
  raise FileValidationError("file is empty")
37
52
  if not os.access(path, os.R_OK):
38
53
  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)")
54
+ limit_bytes = constants.MAX_EMAIL_SIZE_BYTES if max_bytes is None else max_bytes
55
+ if size > limit_bytes:
56
+ limit = f"{_megabytes(limit_bytes):g} MB"
57
+ where = f", the limit for {provider_name}" if provider_name else ""
58
+ raise FileValidationError(f"file exceeds {limit}{where} ({_megabytes(size):.1f} MB)")
@@ -4,12 +4,11 @@ import os
4
4
  import sys
5
5
  import tempfile
6
6
  from pathlib import Path
7
- from typing import Any
7
+ from typing import Annotated, Any, Literal
8
8
 
9
9
  import typer
10
- from pydantic import EmailStr, TypeAdapter, ValidationError
10
+ from pydantic import BeforeValidator, EmailStr, TypeAdapter, ValidationError
11
11
 
12
- from send_2_kindle import constants
13
12
  from send_2_kindle.config import (
14
13
  SmtpHost,
15
14
  SmtpPort,
@@ -18,6 +17,7 @@ from send_2_kindle.config import (
18
17
  read_config_file,
19
18
  )
20
19
  from send_2_kindle.doctor import report, run_checks
20
+ from send_2_kindle.providers import GMAIL, PROVIDERS, provider_for_host, provider_for_key
21
21
 
22
22
  INTRO = """\
23
23
  s2k init: configure Send to Kindle by email.
@@ -26,14 +26,24 @@ You will need:
26
26
  - Your Send to Kindle address: Amazon > Manage Your Content and Devices > Preferences >
27
27
  Personal Document Settings.
28
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}
29
+ {passwords}
30
30
  """
31
+ PASSWORD_LINES = "\n".join(
32
+ f" - {p.name}: an {p.password_name}, not your account password: {p.password_url}"
33
+ for p in PROVIDERS
34
+ )
31
35
  HEADER = "# s2k configuration, written by `s2k init`. Keep it private (chmod 600).\n"
32
36
 
33
37
  _EMAIL: TypeAdapter[Any] = TypeAdapter(EmailStr)
34
38
  _HOST: TypeAdapter[Any] = TypeAdapter(SmtpHost)
35
39
  _PORT: TypeAdapter[Any] = TypeAdapter(SmtpPort)
36
40
  _SECURITY: TypeAdapter[Any] = TypeAdapter(SmtpSecurity)
41
+ _PROVIDER: TypeAdapter[Any] = TypeAdapter(
42
+ Annotated[
43
+ Literal["gmail", "icloud", "other"],
44
+ BeforeValidator(lambda value: value.lower() if isinstance(value, str) else value),
45
+ ]
46
+ )
37
47
 
38
48
 
39
49
  def _is_interactive() -> bool:
@@ -104,20 +114,43 @@ def _ask_password(has_current: bool) -> str | None:
104
114
 
105
115
 
106
116
  def _collect(current: dict[str, str]) -> dict[str, str]:
117
+ existing_host = current.get("S2K_SMTP_HOST")
118
+ known = provider_for_host(existing_host) if existing_host else GMAIL
119
+ choice = _ask("Email provider (gmail/icloud/other)", _PROVIDER, known.key if known else "other")
120
+ provider = provider_for_key(choice.lower())
121
+ sender_label = "Email you send from"
122
+ if provider is not None and provider.key == "icloud":
123
+ sender_label += " (your iCloud Mail address, e.g. name@icloud.com)"
107
124
  values = {
108
125
  "S2K_KINDLE_EMAIL": _ask("Send to Kindle address", _EMAIL, current.get("S2K_KINDLE_EMAIL")),
109
- "S2K_SENDER_EMAIL": _ask("Email you send from", _EMAIL, current.get("S2K_SENDER_EMAIL")),
110
- "S2K_SMTP_HOST": _ask(
111
- "SMTP server", _HOST, current.get("S2K_SMTP_HOST", constants.GMAIL_SMTP_HOST)
112
- ),
113
- "S2K_SMTP_PORT": _ask("SMTP port", _PORT, current.get("S2K_SMTP_PORT", "587")),
114
- "S2K_SMTP_SECURITY": _ask(
115
- "Security (starttls/ssl)", _SECURITY, current.get("S2K_SMTP_SECURITY", "starttls")
116
- ),
126
+ "S2K_SENDER_EMAIL": _ask(sender_label, _EMAIL, current.get("S2K_SENDER_EMAIL")),
117
127
  }
128
+ if provider is not None:
129
+ # Same provider as before: keep a port/security the user chose (e.g. Gmail on 465/ssl).
130
+ same = existing_host is not None and provider_for_host(existing_host) is provider
131
+ values |= {
132
+ "S2K_SMTP_HOST": provider.host,
133
+ "S2K_SMTP_PORT": current.get("S2K_SMTP_PORT", str(provider.port))
134
+ if same
135
+ else str(provider.port),
136
+ "S2K_SMTP_SECURITY": current.get("S2K_SMTP_SECURITY", provider.security)
137
+ if same
138
+ else provider.security,
139
+ }
140
+ typer.echo(f" {provider.name} needs an {provider.password_name}: {provider.password_url}")
141
+ else:
142
+ values |= {
143
+ "S2K_SMTP_HOST": _ask("SMTP server", _HOST, current.get("S2K_SMTP_HOST", GMAIL.host)),
144
+ "S2K_SMTP_PORT": _ask("SMTP port", _PORT, current.get("S2K_SMTP_PORT", "587")),
145
+ "S2K_SMTP_SECURITY": _ask(
146
+ "Security (starttls/ssl)", _SECURITY, current.get("S2K_SMTP_SECURITY", "starttls")
147
+ ),
148
+ }
118
149
  password = _ask_password(bool(current.get("S2K_SMTP_PASSWORD")))
119
150
  values["S2K_SMTP_PASSWORD"] = password if password is not None else current["S2K_SMTP_PASSWORD"]
120
- if current.get("S2K_SMTP_USERNAME"):
151
+ # A custom login belongs to its server: drop it when the server changes.
152
+ old_host = (existing_host or "").strip().lower()
153
+ if current.get("S2K_SMTP_USERNAME") and values["S2K_SMTP_HOST"].strip().lower() == old_host:
121
154
  values["S2K_SMTP_USERNAME"] = current["S2K_SMTP_USERNAME"]
122
155
  return values
123
156
 
@@ -130,7 +163,7 @@ def run_wizard() -> int:
130
163
  )
131
164
  return 2
132
165
  path = config_file_path()
133
- typer.echo(INTRO.format(url=constants.GMAIL_APP_PASSWORDS_URL))
166
+ typer.echo(INTRO.format(passwords=PASSWORD_LINES))
134
167
  try:
135
168
  values = _collect(_read_existing(path))
136
169
  except typer.Abort:
File without changes