s2k-cli 0.1.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.1.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.1.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"
@@ -33,8 +33,10 @@ def config_file_path() -> Path:
33
33
  override = os.environ.get(CONFIG_FILE_VARIABLE)
34
34
  if override:
35
35
  return Path(override).expanduser()
36
- base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
37
- return Path(base).expanduser() / "s2k" / "config.env"
36
+ xdg = os.environ.get("XDG_CONFIG_HOME", "")
37
+ # The XDG spec says relative values must be ignored.
38
+ base = Path(xdg) if xdg and Path(xdg).is_absolute() else Path("~/.config").expanduser()
39
+ return base / "s2k" / "config.env"
38
40
 
39
41
 
40
42
  class Settings(BaseSettings):
@@ -47,7 +49,7 @@ class Settings(BaseSettings):
47
49
  kindle_email: EmailStr
48
50
  sender_email: EmailStr
49
51
  smtp_password: SecretStr
50
- smtp_host: SmtpHost = constants.GMAIL_SMTP_HOST
52
+ smtp_host: SmtpHost = GMAIL.host
51
53
  smtp_port: SmtpPort = 587
52
54
  smtp_security: SmtpSecurity = "starttls"
53
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,14 +37,23 @@ 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
 
45
48
 
49
+ def _security_hint(settings: Settings) -> str:
50
+ if settings.smtp_port == 465 and settings.smtp_security == "starttls":
51
+ return " (port 465 normally needs S2K_SMTP_SECURITY=ssl)"
52
+ if settings.smtp_port == 587 and settings.smtp_security == "ssl":
53
+ return " (port 587 normally needs S2K_SMTP_SECURITY=starttls)"
54
+ return ""
55
+
56
+
46
57
  def _close_quietly(smtp: smtplib.SMTP | None) -> None:
47
58
  if smtp is not None:
48
59
  smtp.close()
@@ -112,6 +123,7 @@ class KindleMailer:
112
123
  _close_quietly(smtp)
113
124
  raise SmtpConnectionError(
114
125
  f"could not connect to {settings.smtp_host}:{settings.smtp_port}: {error}"
126
+ f"{_security_hint(settings)}"
115
127
  ) from error
116
128
  self._smtp = smtp
117
129
  return self
@@ -141,6 +153,10 @@ class KindleMailer:
141
153
  try:
142
154
  message = build_message(self._settings, path)
143
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
144
160
  raise SendError(f"could not read file: {error}") from error
145
161
  try:
146
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:
@@ -46,7 +56,11 @@ def _quote(value: str) -> str:
46
56
 
47
57
 
48
58
  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)."""
59
+ """Atomically write ``values`` as KEY="value" lines to ``path`` (mode 600, new dirs 700).
60
+
61
+ A symlinked config file (e.g. managed by a dotfiles repo) is kept: its target is rewritten.
62
+ """
63
+ path = path.resolve() if path.is_symlink() else path
50
64
  path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
51
65
  content = HEADER + "".join(f"{key}={_quote(value)}\n" for key, value in values.items())
52
66
  fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=".config.env.")
@@ -79,33 +93,64 @@ def _ask(text: str, adapter: TypeAdapter[Any], default: str | None) -> str:
79
93
 
80
94
 
81
95
  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))
96
+ """Return the new password (typed twice), or None to keep the current one."""
97
+ while True:
98
+ if has_current:
99
+ answer = str(
100
+ typer.prompt(
101
+ "SMTP password (empty keeps the current one)",
102
+ default="",
103
+ hide_input=True,
104
+ show_default=False,
105
+ )
106
+ )
107
+ if not answer:
108
+ return None
109
+ else:
110
+ answer = str(typer.prompt("SMTP password", hide_input=True))
111
+ if str(typer.prompt("Repeat the SMTP password", hide_input=True)) == answer:
112
+ return answer
113
+ typer.secho(" The passwords do not match; try again.", fg=typer.colors.YELLOW)
92
114
 
93
115
 
94
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)"
95
124
  values = {
96
125
  "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
- ),
126
+ "S2K_SENDER_EMAIL": _ask(sender_label, _EMAIL, current.get("S2K_SENDER_EMAIL")),
105
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
+ }
106
149
  password = _ask_password(bool(current.get("S2K_SMTP_PASSWORD")))
107
150
  values["S2K_SMTP_PASSWORD"] = password if password is not None else current["S2K_SMTP_PASSWORD"]
108
- 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:
109
154
  values["S2K_SMTP_USERNAME"] = current["S2K_SMTP_USERNAME"]
110
155
  return values
111
156
 
@@ -118,7 +163,7 @@ def run_wizard() -> int:
118
163
  )
119
164
  return 2
120
165
  path = config_file_path()
121
- typer.echo(INTRO.format(url=constants.GMAIL_APP_PASSWORDS_URL))
166
+ typer.echo(INTRO.format(passwords=PASSWORD_LINES))
122
167
  try:
123
168
  values = _collect(_read_existing(path))
124
169
  except typer.Abort:
File without changes