All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH kgl 0/2] Add token_command support for Gmail targets
@ 2026-11-11  6:59 Dmitry Torokhov
  2026-11-11  6:59 ` [PATCH kgl 1/2] Decouple GmailTarget from OAuth file credential management Dmitry Torokhov
                   ` (2 more replies)
  0 siblings, 3 replies; 4+ messages in thread
From: Dmitry Torokhov @ 2026-11-11  6:59 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev

This series adds support for obtaining Gmail OAuth2 bearer tokens from
an external command via a new token_command setting on Gmail targets
(similar to passwordeval in msmtp or PassCmd in mbsync). This lets
korgalore share an encrypted credential store or OAuth2 token helper
(such as oama, git-credential helpers, or custom keyring/GPG scripts)
with other mail tools without managing separate Google OAuth client
secrets, running another browser login flow, or keeping an unencrypted
token JSON file on disk.

Patch 1 extracts file-based OAuth credential management out of
GmailTarget into a dedicated google_creds module so GmailTarget accepts
any google.auth Credentials provider. Patch 2 adds the command-backed
credential provider, wires up token_command, verifies Gmail access
during kgl auth, and documents the new option.

Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
Dmitry Torokhov (2):
      Decouple GmailTarget from OAuth file credential management
      Add token_command support for Gmail targets

 docs/configuration.rst        |  10 +-
 korgalore-example.toml        |   2 +
 man/korgalore.toml.5          |  12 +-
 src/korgalore/cli.py          |  35 +++--
 src/korgalore/gmail_target.py | 225 ++++++++++++++--------------
 src/korgalore/google_creds.py | 336 ++++++++++++++++++++++++++++++++++++++++++
 tests/test_cli_misc.py        | 103 ++++++++++++-
 tests/test_gmail_target.py    | 254 +++++++++++++++++--------------
 tests/test_google_creds.py    | 262 ++++++++++++++++++++++++++++++++
 9 files changed, 997 insertions(+), 242 deletions(-)
---
base-commit: ea22e7c8701e566328f197c748718cd689f9ea28
change-id: 20261104-token-command-2e22bf5ba436

Thanks.

-- 
Dmitry


^ permalink raw reply	[flat|nested] 4+ messages in thread

* [PATCH kgl 1/2] Decouple GmailTarget from OAuth file credential management
  2026-11-11  6:59 [PATCH kgl 0/2] Add token_command support for Gmail targets Dmitry Torokhov
@ 2026-11-11  6:59 ` Dmitry Torokhov
  2026-11-11  6:59 ` [PATCH kgl 2/2] Add token_command support for Gmail targets Dmitry Torokhov
  2026-11-12 19:58 ` [PATCH kgl 0/2] " Konstantin Ryabitsev
  2 siblings, 0 replies; 4+ messages in thread
From: Dmitry Torokhov @ 2026-11-11  6:59 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev

Move OAuth client secrets and token file management out of GmailTarget
into OAuthFileCredentials in google_creds, subclassing
google.auth.credentials.Credentials, and pass credentials directly into
GmailTarget via make_gmail_credentials.

This keeps Google OAuth credential transport separate from Gmail API
delivery and configuration parsing, and prepares for supporting
alternative credential providers.

Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
 src/korgalore/cli.py          |  23 ++---
 src/korgalore/gmail_target.py | 212 ++++++++++++++++++--------------------
 src/korgalore/google_creds.py | 229 ++++++++++++++++++++++++++++++++++++++++++
 tests/test_gmail_target.py    | 229 +++++++++++++++++++++---------------------
 tests/test_google_creds.py    | 154 ++++++++++++++++++++++++++++
 5 files changed, 610 insertions(+), 237 deletions(-)

diff --git a/src/korgalore/cli.py b/src/korgalore/cli.py
index 3e3e615..180906d 100644
--- a/src/korgalore/cli.py
+++ b/src/korgalore/cli.py
@@ -54,7 +54,7 @@ from korgalore.digest import (
     strip_reply_prefixes,
 )
 from korgalore.dummy_target import DummyTarget
-from korgalore.gmail_target import GmailTarget
+from korgalore.gmail_target import GmailTarget, make_gmail_credentials
 from korgalore.imap_target import ImapTarget
 from korgalore.jmap_target import JmapTarget
 from korgalore.lei_feed import LeiFeed
@@ -280,8 +280,7 @@ def get_target(ctx: click.Context, identifier: str) -> Any:
     if target_type == 'gmail':
         service = get_gmail_target(
             identifier=identifier,
-            credentials_file=details.get('credentials', ''),
-            token_file=details.get('token', None),
+            details=details,
             interactive=interactive,
         )
     elif target_type == 'maildir':
@@ -334,19 +333,19 @@ def get_target(ctx: click.Context, identifier: str) -> Any:
 
 
 def get_gmail_target(
-    identifier: str, credentials_file: str, token_file: str | None, interactive: bool = True
+    identifier: str,
+    details: Mapping[str, Any],
+    interactive: bool = True,
 ) -> GmailTarget:
     """Create a Gmail target service instance."""
-    if not credentials_file:
-        logger.critical('No credentials file specified for Gmail target: %s', identifier)
-        raise click.Abort()
-    if not token_file:
-        cfgdir = get_xdg_config_dir()
-        token_file = str(cfgdir / f'gmail-{identifier}-token.json')
     try:
-        gt = GmailTarget(
-            identifier=identifier, credentials_file=credentials_file, token_file=token_file, interactive=interactive
+        creds = make_gmail_credentials(
+            identifier=identifier,
+            details=details,
+            interactive=interactive,
+            config_dir=get_xdg_config_dir(),
         )
+        gt = GmailTarget(identifier=identifier, creds=creds)
     except ConfigurationError as fe:
         logger.critical('Error: %s', str(fe))
         raise click.Abort() from fe
diff --git a/src/korgalore/gmail_target.py b/src/korgalore/gmail_target.py
index c88c3d9..3fd9f8b 100644
--- a/src/korgalore/gmail_target.py
+++ b/src/korgalore/gmail_target.py
@@ -1,22 +1,21 @@
+import base64
 import logging
 import os
+from collections.abc import Mapping
 from pathlib import Path
 from typing import Any
 
-from google.auth.exceptions import RefreshError
-from google.auth.external_account_authorized_user import Credentials as ExternalAccountCredentials
+from google.auth.credentials import Credentials as BaseCredentials
 from google.auth.transport.requests import Request
-from google.oauth2.credentials import Credentials
-from google_auth_oauthlib.flow import InstalledAppFlow  # type: ignore
-from googleapiclient.discovery import build  # type: ignore
-from googleapiclient.errors import HttpError  # type: ignore
+from googleapiclient.discovery import Resource, build  # type: ignore[import-untyped]
+from googleapiclient.errors import HttpError  # type: ignore[import-untyped]
 
 from korgalore import AuthenticationError, ConfigurationError, RemoteError
+from korgalore.google_creds import OAuthFileCredentials
 from korgalore.message import RawMessage
 
 logger = logging.getLogger('korgalore')
 
-
 # If modifying these scopes, delete the file token.json.
 # We need scopes for reading and inserting new emails, but not
 # modifying existing ones.
@@ -25,105 +24,52 @@ SCOPES = [
     'https://www.googleapis.com/auth/gmail.insert',
 ]
 
-# InstalledAppFlow.run_local_server() returns either flavour depending on what
-# the client_secrets file describes. Both expose the valid/expired/refresh_token
-# attributes and the to_json() and refresh() methods used below, so the code
-# does not care which one it gets -- but the declared type has to allow both.
-GmailCredentials = Credentials | ExternalAccountCredentials
-
 
 class GmailTarget:
     """Target class for delivering email messages to Gmail via the API."""
 
     DEFAULT_LABELS: list[str] = ['INBOX', 'UNREAD']
 
-    def __init__(self, identifier: str, credentials_file: str, token_file: str, interactive: bool = True) -> None:
+    def __init__(self, identifier: str, creds: BaseCredentials) -> None:
         """Initialize a GmailTarget instance.
 
         Args:
             identifier: Unique identifier for this Gmail target.
-            credentials_file: Path to the Google OAuth credentials JSON file.
-            token_file: Path to store/load the OAuth token.
-            interactive: If True, run OAuth flow interactively when needed.
-                        If False, raise AuthenticationError instead (for GUI mode).
-
-        Raises:
-            ConfigurationError: If credentials file is not found.
-            AuthenticationError: If token is expired/revoked and re-auth is needed,
-                               or if interactive=False and no token exists.
+            creds: Google API credentials instance.
         """
         self.identifier = identifier
-        self.creds: GmailCredentials | None = None
-        self.service: Any | None = None
+        self.creds = creds
+        self.service: Resource | None = None
         self._label_map: dict[str, str] | None = None
-        # Store expanded paths for potential re-authentication
-        self._credentials_file = Path(os.path.expandvars(credentials_file)).expanduser()
-        self._token_file = Path(os.path.expandvars(token_file)).expanduser()
-        self._needs_auth = False
-        self._interactive = interactive
-        self._load_credentials()
-
-    def _load_credentials(self) -> None:
-        """Load or refresh OAuth credentials for Gmail API access.
 
-        Attempts to load existing credentials from token_file. If not present
-        or expired, initiates OAuth flow using credentials_file.
-
-        Raises:
-            ConfigurationError: If credentials file is not found.
-            AuthenticationError: If token is expired/revoked and re-auth is needed.
-        """
-        # The file token.json stores the user's access and refresh tokens
-        if self._token_file.exists():
-            self.creds = Credentials.from_authorized_user_file(str(self._token_file), SCOPES)  # type: ignore
-
-        # If there are no (valid) credentials available, let the user log in
-        if not self.creds or not self.creds.valid:
-            if self.creds and self.creds.expired and self.creds.refresh_token:
-                try:
-                    self.creds.refresh(Request())  # type: ignore
-                except RefreshError as e:
-                    logger.warning('Gmail token for %s has expired or been revoked.', self.identifier)
-                    invalid_token_file = self._token_file.with_name(f'{self._token_file.name}.invalid')
-                    invalid_token_file.unlink(missing_ok=True)
-                    self._token_file.rename(invalid_token_file)
-                    self._needs_auth = True
-                    if not self._interactive:
-                        # In non-interactive mode, just return - caller will check needs_auth
-                        return
-                    raise AuthenticationError(
-                        f"Gmail token for '{self.identifier}' is invalid. Please re-authenticate.",
-                        target_id=self.identifier,
-                        target_type='gmail',
-                    ) from e
-            elif self._credentials_file.exists():
-                if not self._interactive:
-                    # In non-interactive mode (GUI), don't run OAuth flow
-                    # Just mark as needing auth and return - caller will check needs_auth
-                    self._needs_auth = True
-                    return
-                logger.critical('Log in to Gmail account for %s', self.identifier)
-
-                flow = InstalledAppFlow.from_client_secrets_file(str(self._credentials_file), SCOPES)
-                self.creds = flow.run_local_server(port=0)
-            else:
-                raise ConfigurationError(
-                    f'{self._credentials_file} not found. Please download it from Google Cloud Console.'
-                )
-
-            # Save the credentials for the next run
-            self._token_file.write_text(self.creds.to_json())
-
-        self._needs_auth = False
+    @property
+    def needs_auth(self) -> bool:
+        """Check if this target needs re-authentication."""
+        return bool(getattr(self.creds, 'needs_auth', False))
 
     def connect(self) -> None:
         """Establish connection to the Gmail API service.
 
         Creates the Gmail API service object if not already connected.
+
+        Raises:
+            ConfigurationError: If credentials file is not found during refresh.
+            AuthenticationError: If OAuth token refresh or authentication fails.
         """
-        if self.service is None:
-            logger.debug('Connecting to Gmail service for %s', self.identifier)
-            self.service = build('gmail', 'v1', credentials=self.creds, cache_discovery=False)
+        if self.service is not None:
+            return
+
+        if not self.creds.valid:
+            self.creds.refresh(Request())  # type: ignore[no-untyped-call]
+
+        logger.debug('Connecting to Gmail service for %s', self.identifier)
+        self.service = build('gmail', 'v1', credentials=self.creds, cache_discovery=False)
+        try:
+            self._label_map = {label['name']: label['id'] for label in self.list_labels()}
+        except Exception:
+            self.service = None
+            self._label_map = None
+            raise
 
     def _api(self) -> Any:
         """Return the connected Gmail API service, connecting first if needed.
@@ -163,8 +109,8 @@ class GmailTarget:
         """
         # Translate label names to their corresponding IDs
         if self._label_map is None:
-            # Get all labels from Gmail
-            self._label_map = {label['name']: label['id'] for label in self.list_labels()}
+            self.connect()
+        assert self._label_map is not None
         translated: list[str] = []
         for label in labels:
             label_id = self._label_map.get(label, None)
@@ -197,8 +143,6 @@ class GmailTarget:
             RemoteError: If the Gmail API call fails.
         """
         try:
-            import base64
-
             msg = RawMessage(raw_message)
             encoded_message = base64.urlsafe_b64encode(msg.as_bytes(feed_name, delivery_name)).decode()
             message_body: dict[str, Any] = {'raw': encoded_message}
@@ -215,33 +159,77 @@ class GmailTarget:
         except HttpError as error:
             raise RemoteError(f'An error occurred: {error}') from error
 
-    @property
-    def needs_auth(self) -> bool:
-        """Check if this target needs re-authentication."""
-        return self._needs_auth
-
     def reauthenticate(self) -> None:
         """Perform OAuth re-authentication flow.
 
-        Opens a browser window for the user to log in and authorize the app.
-        Updates credentials upon successful authentication.
+        Delegates interactive re-authentication to the underlying credentials
+        provider and clears cached API state so the next operation reconnects
+        with the updated credentials.
 
         Raises:
             ConfigurationError: If credentials file is not found.
+            AuthenticationError: If interactive re-authentication is unsupported
+                                 or the OAuth flow fails.
         """
-        if not self._credentials_file.exists():
-            raise ConfigurationError(
-                f'{self._credentials_file} not found. Please download it from Google Cloud Console.'
+        reauth = getattr(self.creds, 'reauthenticate', None)
+        if not callable(reauth):
+            raise AuthenticationError(
+                f"Target '{self.identifier}' does not support interactive reauthentication.",
+                target_id=self.identifier,
+                target_type='gmail',
             )
-
-        logger.info('Starting re-authentication for Gmail account %s', self.identifier)
-
-        flow = InstalledAppFlow.from_client_secrets_file(str(self._credentials_file), SCOPES)
-        self.creds = flow.run_local_server(port=0)
-
-        # Save the credentials
-        self._token_file.write_text(self.creds.to_json())
-
-        self._needs_auth = False
-        self.service = None  # Reset service to force reconnect with new creds
-        logger.info('Re-authentication successful for %s', self.identifier)
+        reauth()
+        self.service = None
+        self._label_map = None
+
+
+def make_gmail_credentials(
+    identifier: str,
+    details: Mapping[str, Any],
+    interactive: bool = True,
+    config_dir: Path | str | None = None,
+) -> BaseCredentials:
+    """Create credentials for a Gmail target from configuration.
+
+    Args:
+        identifier: Unique identifier for the Gmail target.
+        details: Target configuration mapping from ``korgalore.toml``.
+        interactive: Whether interactive OAuth browser flows are permitted.
+        config_dir: Optional base configuration directory for default token paths.
+
+    Returns:
+        Configured credentials instance compatible with ``GmailTarget``.
+
+    Raises:
+        ConfigurationError: If required configuration keys are missing or have
+                            invalid types.
+    """
+    if 'credentials' not in details:
+        raise ConfigurationError(f"No credentials file specified for Gmail target '{identifier}'.")
+
+    creds_raw = details['credentials']
+    if not isinstance(creds_raw, str):
+        raise ConfigurationError(f"Gmail target '{identifier}': 'credentials' must be a string.")
+
+    token_raw = details.get('token')
+    if token_raw is not None and not isinstance(token_raw, str):
+        raise ConfigurationError(f"Gmail target '{identifier}': 'token' must be a string.")
+
+    if not token_raw:
+        base_dir = (
+            Path(config_dir)
+            if config_dir is not None
+            else Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'korgalore'
+        )
+        token_file: str | Path = base_dir / f'gmail-{identifier}-token.json'
+    else:
+        token_file = token_raw
+
+    return OAuthFileCredentials(
+        identifier=identifier,
+        credentials_file=creds_raw,
+        token_file=token_file,
+        scopes=SCOPES,
+        interactive=interactive,
+        target_type='gmail',
+    )
diff --git a/src/korgalore/google_creds.py b/src/korgalore/google_creds.py
new file mode 100644
index 0000000..f1963bb
--- /dev/null
+++ b/src/korgalore/google_creds.py
@@ -0,0 +1,229 @@
+"""Google credential providers for korgalore."""
+
+import logging
+import os
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+
+from google.auth.credentials import Credentials as BaseCredentials
+from google.auth.exceptions import RefreshError
+from google.auth.external_account_authorized_user import Credentials as ExternalAccountCredentials
+from google.auth.transport.requests import Request
+from google.oauth2.credentials import Credentials
+from google_auth_oauthlib.flow import InstalledAppFlow  # type: ignore[import-untyped]
+
+from korgalore import AuthenticationError, ConfigurationError
+
+logger = logging.getLogger('korgalore')
+
+# InstalledAppFlow.run_local_server() returns either flavour depending on what
+# the client_secrets file describes. Both expose the valid/expired/refresh_token
+# attributes and the to_json() and refresh() methods used below, so the code
+# does not care which one it gets -- but the declared type has to allow both.
+OAuthCredentials = Credentials | ExternalAccountCredentials
+
+
+class OAuthFileCredentials(BaseCredentials):
+    """Credentials backed by Google OAuth client secrets and a token file."""
+
+    def __init__(
+        self,
+        identifier: str,
+        credentials_file: str,
+        token_file: str | Path,
+        scopes: Sequence[str],
+        interactive: bool = True,
+        target_type: str = 'gmail',
+    ) -> None:
+        """Initialize file-backed OAuth credentials for a Google target.
+
+        Args:
+            identifier: Unique identifier for this target.
+            credentials_file: Path to the Google OAuth credentials JSON file.
+            token_file: Path to store/load the OAuth token.
+            scopes: OAuth scopes to request when running interactive auth.
+            interactive: If True, run OAuth flow interactively when needed.
+                         If False, record ``needs_auth`` instead (for GUI mode).
+            target_type: Target type string reported in AuthenticationError.
+
+        Raises:
+            ConfigurationError: If credentials file path is empty or missing.
+            AuthenticationError: If token is expired/revoked and re-auth is needed,
+                                 or if an interactive OAuth flow fails.
+        """
+        self._creds: OAuthCredentials | None = None
+        super().__init__()  # type: ignore[no-untyped-call]
+        self.identifier = identifier
+        self._interactive = interactive
+        self._scopes = list(scopes)
+        self._target_type = target_type
+        self.needs_auth = False
+
+        if not credentials_file:
+            raise ConfigurationError(f"No credentials file specified for target '{identifier}'.")
+        # Store expanded paths for potential re-authentication
+        self._credentials_file = Path(os.path.expandvars(credentials_file)).expanduser()
+        self._token_file = Path(os.path.expandvars(os.fspath(token_file))).expanduser()
+
+        self._load_credentials()
+
+    def _load_credentials(self) -> None:
+        """Load or refresh OAuth credentials for API access.
+
+        Attempts to load existing credentials from ``self._token_file``. If
+        not present or expired, refreshes with the stored refresh token or
+        initiates the OAuth flow using ``self._credentials_file``.
+
+        Raises:
+            ConfigurationError: If credentials file is not found.
+            AuthenticationError: If token is expired/revoked and re-auth is needed.
+        """
+        # The file token.json stores the user's access and refresh tokens
+        if self._token_file.exists():
+            try:
+                self._creds = Credentials.from_authorized_user_file(str(self._token_file))  # type: ignore[no-untyped-call]
+            except Exception as e:
+                logger.warning('Failed to load OAuth token for %s from %s: %s', self.identifier, self._token_file, e)
+                self._creds = None
+
+        # If there are no (valid) credentials available, let the user log in
+        if not self._creds or not self._creds.valid:
+            if self._creds and self._creds.expired and self._creds.refresh_token:
+                try:
+                    self._creds.refresh(Request())  # type: ignore[no-untyped-call]
+                    self._save_token()
+                except RefreshError as e:
+                    logger.warning('OAuth token for target %s has expired or been revoked.', self.identifier)
+                    self._quarantine_token()
+                    self.needs_auth = True
+                    if not self._interactive:
+                        # In non-interactive mode, just return - caller will check needs_auth
+                        return
+                    raise AuthenticationError(
+                        f"OAuth token for '{self.identifier}' is invalid. Please re-authenticate.",
+                        target_id=self.identifier,
+                        target_type=self._target_type,
+                    ) from e
+            elif not self._credentials_file.exists():
+                raise ConfigurationError(
+                    f"Credentials file not found for target '{self.identifier}': {self._credentials_file}"
+                )
+            elif not self._interactive:
+                # In non-interactive mode (GUI), don't run OAuth flow
+                # Just mark as needing auth and return - caller will check needs_auth
+                self.needs_auth = True
+                return
+            else:
+                self.reauthenticate()
+                return
+
+        self.needs_auth = False
+
+    @property
+    def token(self) -> str | None:
+        """Current OAuth2 access token, or None if not loaded."""
+        if self._creds and self._creds.token is not None:
+            return str(self._creds.token)
+        return None
+
+    @token.setter
+    def token(self, value: str | None) -> None:
+        if self._creds:
+            self._creds.token = value
+
+    @property
+    def expiry(self) -> Any:
+        """Expiration timestamp of the current access token, or None."""
+        return self._creds.expiry if self._creds else None
+
+    @expiry.setter
+    def expiry(self, value: Any) -> None:
+        if self._creds:
+            self._creds.expiry = value
+
+    @property
+    def valid(self) -> bool:
+        """Whether loaded OAuth2 credentials are present and currently valid."""
+        return bool(self._creds and self._creds.valid)
+
+    def _save_token(self) -> None:
+        """Persist current OAuth credentials to the configured token file."""
+        if self._creds is None:
+            return
+        # Save the credentials for the next run
+        self._token_file.parent.mkdir(parents=True, exist_ok=True)
+        self._token_file.write_text(self._creds.to_json())  # type: ignore[no-untyped-call]
+        self._token_file.chmod(0o600)
+
+    def _quarantine_token(self) -> None:
+        """Rename a revoked or invalid token file aside to ``.invalid``."""
+        if self._token_file.exists():
+            invalid_token_file = self._token_file.with_name(f'{self._token_file.name}.invalid')
+            invalid_token_file.unlink(missing_ok=True)
+            self._token_file.rename(invalid_token_file)
+
+    def refresh(self, request: Any = None) -> None:
+        """Refresh the access token using the stored refresh token.
+
+        Args:
+            request: Optional Google auth transport request object.
+
+        Raises:
+            ConfigurationError: If credentials file is missing during
+                                interactive re-authentication.
+            AuthenticationError: If refresh fails or no token is loaded in
+                                 non-interactive mode.
+        """
+        if self._creds is None:
+            if self._interactive:
+                self.reauthenticate()
+                return
+            self.needs_auth = True
+            raise AuthenticationError(
+                f"Target '{self.identifier}' requires authentication.",
+                target_id=self.identifier,
+                target_type=self._target_type,
+            )
+        req = request or Request()
+        try:
+            self._creds.refresh(req)  # type: ignore[no-untyped-call]
+        except RefreshError as e:
+            logger.warning('OAuth token for target %s has expired or been revoked.', self.identifier)
+            self._quarantine_token()
+            self.needs_auth = True
+            raise AuthenticationError(
+                f"Failed to refresh credentials for target '{self.identifier}': {e}",
+                target_id=self.identifier,
+                target_type=self._target_type,
+            ) from e
+        self._save_token()
+        self.needs_auth = False
+
+    def reauthenticate(self) -> None:
+        """Perform OAuth re-authentication flow.
+
+        Opens a browser window for the user to log in and authorize the app.
+        Updates credentials upon successful authentication.
+
+        Raises:
+            ConfigurationError: If credentials file is not found.
+            AuthenticationError: If the local OAuth authorization flow fails.
+        """
+        if not self._credentials_file.exists():
+            raise ConfigurationError(
+                f"Credentials file not found for target '{self.identifier}': {self._credentials_file}"
+            )
+        logger.critical('Log in to account for %s', self.identifier)
+        try:
+            flow = InstalledAppFlow.from_client_secrets_file(str(self._credentials_file), self._scopes)
+            self._creds = flow.run_local_server(port=0)
+        except Exception as e:
+            self.needs_auth = True
+            raise AuthenticationError(
+                f"OAuth flow failed for target '{self.identifier}': {e}",
+                target_id=self.identifier,
+                target_type=self._target_type,
+            ) from e
+        self._save_token()
+        self.needs_auth = False
diff --git a/tests/test_gmail_target.py b/tests/test_gmail_target.py
index c3a3ed4..9e27f1c 100644
--- a/tests/test_gmail_target.py
+++ b/tests/test_gmail_target.py
@@ -5,28 +5,25 @@ from pathlib import Path
 from unittest.mock import MagicMock, patch
 
 import pytest
-from google.auth.exceptions import RefreshError
+from google.auth.credentials import Credentials as BaseCredentials
 from googleapiclient.errors import HttpError  # type: ignore[import-untyped]
 
-from korgalore import ConfigurationError, RemoteError
-from korgalore.gmail_target import SCOPES, GmailTarget
+from korgalore import AuthenticationError, ConfigurationError, RemoteError
+from korgalore.gmail_target import SCOPES, GmailTarget, make_gmail_credentials
+from korgalore.google_creds import OAuthFileCredentials
 
 
-def make_target(tmp_path: Path) -> tuple[GmailTarget, MagicMock]:
-    """A GmailTarget with valid stored credentials; returns it with those credentials."""
-    token = tmp_path / 'token.json'
-    token.write_text('{}')
-    with patch('korgalore.gmail_target.Credentials') as mock_creds_class:
-        mock_creds = MagicMock()
-        mock_creds.valid = True
-        mock_creds_class.from_authorized_user_file.return_value = mock_creds
-        return GmailTarget('test', str(tmp_path / 'creds.json'), str(token)), mock_creds
+def make_target() -> tuple[GmailTarget, MagicMock]:
+    """A GmailTarget with valid mock credentials."""
+    mock_creds = MagicMock(spec=BaseCredentials)
+    mock_creds.valid = True
+    return GmailTarget('test', creds=mock_creds), mock_creds
 
 
 @pytest.fixture
-def gmail(tmp_path: Path) -> tuple[GmailTarget, MagicMock]:
+def gmail() -> tuple[GmailTarget, MagicMock]:
     """A target with a mocked API service; the label map is not loaded yet."""
-    target, _ = make_target(tmp_path)
+    target, _ = make_target()
     service = MagicMock()
     target.service = service
     return target, service
@@ -52,123 +49,85 @@ def import_body(service: MagicMock) -> dict[str, object]:
 
 
 class TestGmailTargetInit:
-    """Tests for GmailTarget initialization."""
+    """Tests for GmailTarget initialization and credential delegation."""
 
-    @patch('korgalore.gmail_target.Credentials')
-    def test_loads_existing_valid_token(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
-        """Loads credentials from the stored token; the service waits for connect()."""
-        token = tmp_path / 'token.json'
-        token.write_text('{}')
-        mock_creds = MagicMock()
-        mock_creds.valid = True
-        mock_credentials.from_authorized_user_file.return_value = mock_creds
-
-        target = GmailTarget('test', str(tmp_path / 'creds.json'), str(token))
+    def test_init_with_credentials(self) -> None:
+        """Stores identifier and credentials without building API service yet."""
+        target, mock_creds = make_target()
 
         assert target.identifier == 'test'
         assert target.creds is mock_creds
         assert target.service is None
-        mock_credentials.from_authorized_user_file.assert_called_once_with(str(token), SCOPES)
-
-    @patch('korgalore.gmail_target.Credentials')
-    @patch('korgalore.gmail_target.Request')
-    def test_refreshes_expired_token(
-        self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
-    ) -> None:
-        """Refreshes expired credentials with refresh token and saves the result."""
-        token = tmp_path / 'token.json'
-        token.write_text('{"token": "stale"}')
-        mock_creds = MagicMock()
-        mock_creds.valid = False
-        mock_creds.expired = True
-        mock_creds.refresh_token = 'refresh_token_value'
-        mock_creds.to_json.return_value = '{"token": "refreshed"}'
-        mock_credentials.from_authorized_user_file.return_value = mock_creds
-
-        GmailTarget('test', str(tmp_path / 'creds.json'), str(token))
-
-        mock_creds.refresh.assert_called_once()
-        assert token.read_text() == '{"token": "refreshed"}'
 
-    @patch('korgalore.gmail_target.Credentials')
-    @patch('korgalore.gmail_target.Request')
-    def test_revoked_token_is_set_aside(
-        self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
-    ) -> None:
-        """A token that fails to refresh is renamed out of the way and auth is flagged."""
-        token = tmp_path / 'token.json'
-        token.write_text('{"token": "revoked"}')
-        invalid = tmp_path / 'token.json.invalid'
-        invalid.write_text('{"token": "older"}')
-        mock_creds = MagicMock()
-        mock_creds.valid = False
-        mock_creds.expired = True
-        mock_creds.refresh_token = 'refresh_token_value'
-        mock_creds.refresh.side_effect = RefreshError
-        mock_credentials.from_authorized_user_file.return_value = mock_creds
-
-        target = GmailTarget('test', str(tmp_path / 'creds.json'), str(token), interactive=False)
-
-        assert target.needs_auth
-        assert not token.exists()
-        assert invalid.read_text() == '{"token": "revoked"}'
-
-    @patch('korgalore.gmail_target.InstalledAppFlow')
-    def test_runs_oauth_flow_and_saves_token_when_no_token(self, mock_flow_class: MagicMock, tmp_path: Path) -> None:
-        """Runs the OAuth flow when no token exists, then saves the new token."""
-        creds = tmp_path / 'creds.json'
-        creds.write_text('{}')
-        token = tmp_path / 'token.json'
-        mock_creds = MagicMock()
-        mock_creds.to_json.return_value = '{"access_token": "new_token"}'
-        mock_flow = MagicMock()
-        mock_flow.run_local_server.return_value = mock_creds
-        mock_flow_class.from_client_secrets_file.return_value = mock_flow
-
-        target = GmailTarget('test', str(creds), str(token))
-
-        mock_flow_class.from_client_secrets_file.assert_called_once_with(str(creds), SCOPES)
-        mock_flow.run_local_server.assert_called_once_with(port=0)
-        assert target.creds is mock_creds
-        assert token.read_text() == '{"access_token": "new_token"}'
+    def test_reauthenticate_delegates_and_resets_service(self, gmail_labels: tuple[GmailTarget, MagicMock]) -> None:
+        """Delegates reauthenticate() to creds and clears cached service state."""
+        target, _ = gmail_labels
+        reauth_mock = MagicMock()
+        setattr(target.creds, 'reauthenticate', reauth_mock)
 
-    def test_missing_credentials_file_raises(self, tmp_path: Path) -> None:
-        """Missing credentials file raises ConfigurationError."""
-        # Neither the token file nor the credentials file exists
-        with pytest.raises(ConfigurationError) as exc_info:
-            GmailTarget('test', str(tmp_path / 'creds.json'), str(tmp_path / 'token.json'))
-        assert 'not found' in str(exc_info.value)
-        assert 'creds.json' in str(exc_info.value)
+        target.reauthenticate()
 
-    @patch('korgalore.gmail_target.Credentials')
-    def test_expands_user_paths(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
-        """Tilde and env vars in paths are expanded."""
-        token = tmp_path / 'token.json'
-        token.write_text('{}')
-        mock_credentials.from_authorized_user_file.return_value = MagicMock(valid=True)
+        reauth_mock.assert_called_once_with()
+        assert target.service is None
+        assert target._label_map is None
 
-        with patch.dict('os.environ', {'HOME': str(tmp_path)}):
-            GmailTarget('test', '~/creds.json', '$HOME/token.json')
+    def test_reauthenticate_unsupported_raises(self) -> None:
+        """Raises AuthenticationError when credentials do not support reauth."""
+        target, _ = make_target()
 
-        mock_credentials.from_authorized_user_file.assert_called_once_with(str(token), SCOPES)
+        with pytest.raises(AuthenticationError, match='does not support interactive reauthentication'):
+            target.reauthenticate()
 
 
 class TestGmailTargetConnect:
     """Tests for GmailTarget connect method."""
 
     @patch('korgalore.gmail_target.build')
-    def test_connect_builds_service_once(self, mock_build: MagicMock, tmp_path: Path) -> None:
+    def test_connect_builds_service_once(self, mock_build: MagicMock) -> None:
         """Connect builds the Gmail API service, and repeated calls don't rebuild it."""
-        target, mock_creds = make_target(tmp_path)
+        target, mock_creds = make_target()
         mock_service = MagicMock()
+        mock_service.users().labels().list().execute.return_value = {'labels': [{'id': 'INBOX', 'name': 'INBOX'}]}
         mock_build.return_value = mock_service
 
         target.connect()
+        mock_build.assert_called_once_with('gmail', 'v1', credentials=mock_creds, cache_discovery=False)
+        assert target.service is mock_service
+        assert target._label_map == {'INBOX': 'INBOX'}
+
         target.connect()
         target.connect()
+        mock_build.assert_called_once()
 
-        mock_build.assert_called_once_with('gmail', 'v1', credentials=mock_creds, cache_discovery=False)
-        assert target.service is mock_service
+    @patch('korgalore.gmail_target.build')
+    def test_connect_failure_resets_state(self, mock_build: MagicMock) -> None:
+        """Label probe failure resets service and label map to None."""
+        target, _ = make_target()
+        mock_service = MagicMock()
+        mock_service.users().labels().list().execute.side_effect = http_error(403, b'Forbidden')
+        mock_build.return_value = mock_service
+
+        with pytest.raises(RemoteError):
+            target.connect()
+
+        assert target.service is None
+        assert target._label_map is None
+
+    @patch('korgalore.gmail_target.build')
+    @patch('korgalore.gmail_target.Request')
+    def test_connect_refreshes_invalid_credentials(self, mock_request: MagicMock, mock_build: MagicMock) -> None:
+        """Refreshes invalid credentials before building the API service."""
+        target, mock_creds = make_target()
+        mock_creds.valid = False
+        mock_service = MagicMock()
+        mock_service.users().labels().list().execute.return_value = {'labels': []}
+        mock_build.return_value = mock_service
+
+        target.connect()
+
+        mock_creds.refresh.assert_called_once_with(mock_request.return_value)
+        mock_build.assert_called_once()
+        assert target._label_map == {}
 
 
 class TestGmailTargetListLabels:
@@ -215,9 +174,12 @@ class TestGmailTargetListLabels:
 class TestGmailTargetTranslateLabels:
     """Tests for GmailTarget translate_labels method."""
 
-    def test_translate_labels(self, gmail: tuple[GmailTarget, MagicMock]) -> None:
+    @patch('korgalore.gmail_target.build')
+    def test_translate_labels(self, mock_build: MagicMock) -> None:
         """Label names become IDs, in order; the label map is fetched only once."""
-        target, service = gmail
+        target, _ = make_target()
+        service = MagicMock()
+        mock_build.return_value = service
         service.users().labels().list().execute.return_value = {
             'labels': [
                 {'id': 'INBOX', 'name': 'INBOX'},
@@ -231,9 +193,13 @@ class TestGmailTargetTranslateLabels:
         assert target.translate_labels(['MyLabel']) == ['Label_123']
 
         assert service.users().labels().list().execute.call_count == 1
+        assert mock_build.call_count == 1
 
-    def test_translate_unknown_label_raises(self, gmail: tuple[GmailTarget, MagicMock]) -> None:
-        target, service = gmail
+    @patch('korgalore.gmail_target.build')
+    def test_translate_unknown_label_raises(self, mock_build: MagicMock) -> None:
+        target, _ = make_target()
+        service = MagicMock()
+        mock_build.return_value = service
         service.users().labels().list().execute.return_value = {'labels': [{'id': 'INBOX', 'name': 'INBOX'}]}
 
         with pytest.raises(ConfigurationError) as exc_info:
@@ -241,8 +207,8 @@ class TestGmailTargetTranslateLabels:
         assert 'not found' in str(exc_info.value)
         assert 'NonExistent' in str(exc_info.value)
 
-    def test_label_names_are_case_sensitive(self, gmail: tuple[GmailTarget, MagicMock]) -> None:
-        target, _ = gmail
+    def test_label_names_are_case_sensitive(self) -> None:
+        target, _ = make_target()
         target._label_map = {'INBOX': 'INBOX', 'inbox': 'inbox_lower'}
 
         assert target.translate_labels(['INBOX']) == ['INBOX']
@@ -329,3 +295,40 @@ class TestGmailTargetImportMessage:
         results = [target.import_message(f'Message {i}'.encode(), ['INBOX']) for i in range(5)]
 
         assert [r['id'] for r in results] == ['msg0', 'msg1', 'msg2', 'msg3', 'msg4']
+
+
+class TestMakeGmailCredentials:
+    """Tests for make_gmail_credentials configuration factory."""
+
+    def test_no_credentials_raises(self) -> None:
+        """Omitting credentials raises ConfigurationError."""
+        with pytest.raises(ConfigurationError, match='No credentials file specified'):
+            make_gmail_credentials('test', {})
+
+    @pytest.mark.parametrize(
+        ('details', 'expected_match'),
+        [
+            ({'credentials': 123}, "'credentials' must be a string"),
+            ({'credentials': '/tmp/creds.json', 'token': 456}, "'token' must be a string"),
+        ],
+        ids=['non-str-credentials', 'non-str-token'],
+    )
+    def test_invalid_option_types_raise(self, details: dict[str, object], expected_match: str) -> None:
+        """Non-string configuration values raise ConfigurationError."""
+        with pytest.raises(ConfigurationError, match=expected_match):
+            make_gmail_credentials('test', details)
+
+    @patch('korgalore.google_creds.Credentials')
+    def test_default_token_file_uses_xdg_config_dir(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
+        """When token is omitted, defaults to gmail-{identifier}-token.json in XDG config dir."""
+        default_token = tmp_path / 'korgalore' / 'gmail-personal-token.json'
+        default_token.parent.mkdir(parents=True)
+        default_token.write_text('{}')
+        mock_credentials.from_authorized_user_file.return_value = MagicMock(valid=True)
+
+        with patch.dict('os.environ', {'XDG_CONFIG_HOME': str(tmp_path)}):
+            creds = make_gmail_credentials('personal', {'credentials': str(tmp_path / 'creds.json')})
+
+        mock_credentials.from_authorized_user_file.assert_called_once_with(str(default_token))
+        assert isinstance(creds, OAuthFileCredentials)
+        assert creds._scopes == SCOPES
diff --git a/tests/test_google_creds.py b/tests/test_google_creds.py
new file mode 100644
index 0000000..4ac0a3d
--- /dev/null
+++ b/tests/test_google_creds.py
@@ -0,0 +1,154 @@
+"""Tests for Google OAuth2 credential providers."""
+
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from google.auth.exceptions import RefreshError
+
+from korgalore import AuthenticationError, ConfigurationError
+from korgalore.google_creds import OAuthFileCredentials
+
+TEST_SCOPES = ['https://example.invalid/scope.a', 'https://example.invalid/scope.b']
+
+
+class TestOAuthFileCredentials:
+    """Tests for OAuthFileCredentials initialization, refresh, and reauth."""
+
+    @patch('korgalore.google_creds.Credentials')
+    def test_loads_existing_valid_token(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
+        """Loads credentials from the stored token when already valid."""
+        token = tmp_path / 'token.json'
+        token.write_text('{}')
+        mock_creds = MagicMock()
+        mock_creds.valid = True
+        mock_creds.token = 'access-tok'
+        mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+        provider = OAuthFileCredentials('test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES)
+
+        assert provider.valid
+        assert provider.token == 'access-tok'
+        assert not provider.needs_auth
+        mock_credentials.from_authorized_user_file.assert_called_once_with(str(token))
+
+    @patch('korgalore.google_creds.Credentials')
+    @patch('korgalore.google_creds.Request')
+    def test_refreshes_expired_token(
+        self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
+    ) -> None:
+        """Refreshes expired credentials with refresh token and saves the result."""
+        token = tmp_path / 'token.json'
+        token.write_text('{"token": "stale"}')
+        mock_creds = MagicMock()
+        mock_creds.valid = False
+        mock_creds.expired = True
+        mock_creds.refresh_token = 'refresh_token_value'
+        mock_creds.to_json.return_value = '{"token": "refreshed"}'
+        mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+        OAuthFileCredentials('test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES)
+
+        mock_creds.refresh.assert_called_once()
+        assert token.read_text() == '{"token": "refreshed"}'
+        assert (token.stat().st_mode & 0o777) == 0o600
+
+    @patch('korgalore.google_creds.Credentials')
+    @patch('korgalore.google_creds.Request')
+    def test_revoked_token_is_set_aside(
+        self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
+    ) -> None:
+        """A token that fails to refresh is renamed aside and needs_auth is flagged."""
+        token = tmp_path / 'token.json'
+        token.write_text('{"token": "revoked"}')
+        invalid = tmp_path / 'token.json.invalid'
+        invalid.write_text('{"token": "older"}')
+        mock_creds = MagicMock()
+        mock_creds.valid = False
+        mock_creds.expired = True
+        mock_creds.refresh_token = 'refresh_token_value'
+        mock_creds.refresh.side_effect = RefreshError
+        mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+        provider = OAuthFileCredentials(
+            'test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES, interactive=False
+        )
+
+        assert provider.needs_auth
+        assert not token.exists()
+        assert invalid.read_text() == '{"token": "revoked"}'
+
+    @patch('korgalore.google_creds.Credentials')
+    @patch('korgalore.google_creds.Request')
+    def test_revoked_token_interactive_raises_authentication_error(
+        self, mock_request: MagicMock, mock_credentials: MagicMock, tmp_path: Path
+    ) -> None:
+        """Interactive load raises AuthenticationError when token refresh is revoked."""
+        token = tmp_path / 'token.json'
+        token.write_text('{"token": "revoked"}')
+        mock_creds = MagicMock()
+        mock_creds.valid = False
+        mock_creds.expired = True
+        mock_creds.refresh_token = 'refresh_token_value'
+        mock_creds.refresh.side_effect = RefreshError
+        mock_credentials.from_authorized_user_file.return_value = mock_creds
+
+        with pytest.raises(AuthenticationError, match='invalid'):
+            OAuthFileCredentials('test', str(tmp_path / 'creds.json'), token, scopes=TEST_SCOPES, interactive=True)
+
+        assert not token.exists()
+        assert token.with_name('token.json.invalid').exists()
+
+    @patch('korgalore.google_creds.InstalledAppFlow')
+    def test_runs_oauth_flow_and_saves_token_when_no_token(self, mock_flow_class: MagicMock, tmp_path: Path) -> None:
+        """Runs the OAuth flow interactively, saves the returned credentials, and marks auth done."""
+        creds = tmp_path / 'creds.json'
+        creds.write_text('{}')
+        token = tmp_path / 'token.json'
+        mock_creds = MagicMock()
+        mock_creds.valid = True
+        mock_creds.to_json.return_value = '{"token": "fresh"}'
+        mock_flow = MagicMock()
+        mock_flow.run_local_server.return_value = mock_creds
+        mock_flow_class.from_client_secrets_file.return_value = mock_flow
+
+        provider = OAuthFileCredentials('test', str(creds), token, scopes=TEST_SCOPES)
+
+        mock_flow_class.from_client_secrets_file.assert_called_once_with(str(creds), TEST_SCOPES)
+        mock_flow.run_local_server.assert_called_once_with(port=0)
+        assert provider.valid
+        assert not provider.needs_auth
+        assert token.read_text() == '{"token": "fresh"}'
+        assert (token.stat().st_mode & 0o777) == 0o600
+
+    def test_missing_credentials_file_raises(self, tmp_path: Path) -> None:
+        """Missing credentials file raises ConfigurationError."""
+        with pytest.raises(ConfigurationError) as exc_info:
+            OAuthFileCredentials('test', str(tmp_path / 'creds.json'), tmp_path / 'token.json', scopes=TEST_SCOPES)
+        assert 'not found' in str(exc_info.value)
+        assert 'creds.json' in str(exc_info.value)
+
+    @patch('korgalore.google_creds.Credentials')
+    def test_expands_user_paths(self, mock_credentials: MagicMock, tmp_path: Path) -> None:
+        """Tilde and environment variables in paths are expanded."""
+        token = tmp_path / 'token.json'
+        token.write_text('{}')
+        creds = tmp_path / 'creds.json'
+        creds.write_text('{}')
+        mock_credentials.from_authorized_user_file.return_value = MagicMock(valid=True)
+
+        with patch.dict('os.environ', {'HOME': str(tmp_path)}):
+            OAuthFileCredentials('test', '~/creds.json', '$HOME/token.json', scopes=TEST_SCOPES)
+
+        mock_credentials.from_authorized_user_file.assert_called_once_with(str(token))
+
+    def test_corrupt_token_file_falls_back_gracefully(self, tmp_path: Path) -> None:
+        """Corrupt or truncated token.json logs warning and leaves creds unloaded instead of crashing."""
+        token = tmp_path / 'token.json'
+        token.write_text('not valid json{')
+        creds = tmp_path / 'creds.json'
+        creds.write_text('{}')
+
+        provider = OAuthFileCredentials('test', str(creds), token, scopes=TEST_SCOPES, interactive=False)
+        assert provider.needs_auth
+        assert not provider.valid

-- 
2.56.0.542.g3ef89b7020-goog


^ permalink raw reply related	[flat|nested] 4+ messages in thread

* [PATCH kgl 2/2] Add token_command support for Gmail targets
  2026-11-11  6:59 [PATCH kgl 0/2] Add token_command support for Gmail targets Dmitry Torokhov
  2026-11-11  6:59 ` [PATCH kgl 1/2] Decouple GmailTarget from OAuth file credential management Dmitry Torokhov
@ 2026-11-11  6:59 ` Dmitry Torokhov
  2026-11-12 19:58 ` [PATCH kgl 0/2] " Konstantin Ryabitsev
  2 siblings, 0 replies; 4+ messages in thread
From: Dmitry Torokhov @ 2026-11-11  6:59 UTC (permalink / raw)
  To: Kernel.org Tools; +Cc: Konstantin Ryabitsev

Add support for obtaining Gmail OAuth access tokens from an external
command via the token_command configuration setting on Gmail targets.

This enables integrating with system token managers, secret stores, or
command-line helpers without needing Google OAuth client secrets or
running interactive OAuth login flows in a browser.

Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
 docs/configuration.rst        |  10 +++-
 korgalore-example.toml        |   2 +
 man/korgalore.toml.5          |  12 ++++-
 src/korgalore/cli.py          |  12 ++++-
 src/korgalore/gmail_target.py |  27 +++++++---
 src/korgalore/google_creds.py | 111 +++++++++++++++++++++++++++++++++++++++-
 tests/test_cli_misc.py        | 103 +++++++++++++++++++++++++++++++++++++-
 tests/test_gmail_target.py    |  35 +++++++++++--
 tests/test_google_creds.py    | 114 ++++++++++++++++++++++++++++++++++++++++--
 9 files changed, 404 insertions(+), 22 deletions(-)

diff --git a/docs/configuration.rst b/docs/configuration.rst
index fbba776..734ab26 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -410,9 +410,17 @@ Gmail Target Parameters
 ~~~~~~~~~~~~~~~~~~~~~~~~
 
 * ``type``: Must be ``'gmail'``
-* ``credentials``: Path to the OAuth credentials JSON file
+* ``credentials``: Path to the OAuth credentials JSON file (required unless
+  ``token_command`` is specified)
 * ``token``: (Optional) Path to store the OAuth token. Defaults to
   ``~/.config/korgalore/gmail-{identifier}-token.json``
+* ``token_command``: (Optional) External command that prints a valid OAuth2
+  bearer token to stdout. Mutually exclusive with both ``credentials`` and
+  ``token``. The command string is split using standard command-line syntax
+  and executed directly without a shell, with tilde and environment variable
+  expansion. The bearer token returned by the command must carry the OAuth
+  scopes required by korgalore (``https://www.googleapis.com/auth/gmail.labels``
+  and ``https://www.googleapis.com/auth/gmail.insert``).
 
 Maildir Target Parameters
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/korgalore-example.toml b/korgalore-example.toml
index b7d8164..0551bc4 100644
--- a/korgalore-example.toml
+++ b/korgalore-example.toml
@@ -3,6 +3,8 @@
 [targets.personal]
 type = 'gmail'
 credentials = '/path/to/personal-credentials.json'
+# Alternatively, obtain tokens from an external command:
+# token_command = 'oama access personal'
 
 [targets.work]
 type = 'gmail'
diff --git a/man/korgalore.toml.5 b/man/korgalore.toml.5
index f72d242..3d23a74 100644
--- a/man/korgalore.toml.5
+++ b/man/korgalore.toml.5
@@ -30,11 +30,21 @@ name and a type that determines which parameters are required.
 Must be \fI'gmail'\fR.
 .TP
 .B credentials
-Path to the OAuth credentials JSON file from Google Cloud Console.
+Path to the OAuth credentials JSON file from Google Cloud Console (required
+unless \fBtoken_command\fR is specified).
 .TP
 .B token
 (Optional) Path to store the OAuth token. Defaults to
 \fI~/.config/korgalore/gmail-<name>-token.json\fR.
+.TP
+.B token_command
+(Optional) External command to execute that prints an OAuth2 bearer token to
+stdout. Mutually exclusive with both \fBcredentials\fR and \fBtoken\fR.
+The command is split into arguments using standard command-line syntax and
+executed directly without a shell, with tilde and environment variable
+expansion. The bearer token must carry the OAuth scopes required by
+korgalore (\fIhttps://www.googleapis.com/auth/gmail.labels\fR and
+\fIhttps://www.googleapis.com/auth/gmail.insert\fR).
 .RE
 .PP
 .B Maildir Targets
diff --git a/src/korgalore/cli.py b/src/korgalore/cli.py
index 180906d..e98d4be 100644
--- a/src/korgalore/cli.py
+++ b/src/korgalore/cli.py
@@ -1948,6 +1948,9 @@ def auth(ctx: click.Context, target: str | None) -> None:
             ts = get_target(ctx, identifier)
             ts.connect()
             logger.info('Authenticated target: %s (type: %s)', identifier, target_type)
+        except (ConfigurationError, AuthenticationError, RemoteError) as e:
+            logger.error('Failed to authenticate target %s: %s', identifier, e)
+            raise click.Abort() from e
         except click.Abort:
             logger.error('Failed to authenticate target: %s', identifier)
             raise
@@ -2483,7 +2486,12 @@ def yank(ctx: click.Context, target: str | None, labels: tuple[str, ...], thread
         uploaded = 0
         failed = 0
 
-        ts.connect()
+        try:
+            ts.connect()
+        except (ConfigurationError, AuthenticationError, liblore.RemoteError) as e:
+            logger.critical('Failed to connect to target "%s": %s', target, e)
+            raise click.Abort() from e
+
         with click.progressbar(
             messages, label='Uploading thread', show_pos=True, file=progress_file(ctx.obj['hide_bar'])
         ) as bar:
@@ -2524,7 +2532,7 @@ def yank(ctx: click.Context, target: str | None, labels: tuple[str, ...], thread
             ts.connect()
             ts.import_message(raw_message, labels=labels_list)
             logger.info('Successfully uploaded message.')
-        except liblore.RemoteError as e:
+        except (ConfigurationError, AuthenticationError, liblore.RemoteError) as e:
             logger.critical('Failed to upload message: %s', str(e))
             raise click.Abort() from e
 
diff --git a/src/korgalore/gmail_target.py b/src/korgalore/gmail_target.py
index 3fd9f8b..868e148 100644
--- a/src/korgalore/gmail_target.py
+++ b/src/korgalore/gmail_target.py
@@ -11,7 +11,7 @@ from googleapiclient.discovery import Resource, build  # type: ignore[import-unt
 from googleapiclient.errors import HttpError  # type: ignore[import-untyped]
 
 from korgalore import AuthenticationError, ConfigurationError, RemoteError
-from korgalore.google_creds import OAuthFileCredentials
+from korgalore.google_creds import CommandCredentials, OAuthFileCredentials, parse_token_command
 from korgalore.message import RawMessage
 
 logger = logging.getLogger('korgalore')
@@ -53,8 +53,11 @@ class GmailTarget:
         Creates the Gmail API service object if not already connected.
 
         Raises:
-            ConfigurationError: If credentials file is not found during refresh.
+            ConfigurationError: If credentials file is missing or token_command
+                                cannot be executed.
             AuthenticationError: If OAuth token refresh or authentication fails.
+            RemoteError: If token_command fails, times out, or returns an
+                         invalid token.
         """
         if self.service is not None:
             return
@@ -201,11 +204,23 @@ def make_gmail_credentials(
         Configured credentials instance compatible with ``GmailTarget``.
 
     Raises:
-        ConfigurationError: If required configuration keys are missing or have
-                            invalid types.
+        ConfigurationError: If required configuration keys are missing,
+                            mutually exclusive, or invalid.
     """
-    if 'credentials' not in details:
-        raise ConfigurationError(f"No credentials file specified for Gmail target '{identifier}'.")
+    has_creds = 'credentials' in details
+    has_token = 'token' in details
+    has_cmd = 'token_command' in details
+
+    if has_cmd:
+        if has_creds or has_token:
+            raise ConfigurationError(
+                f"Gmail target '{identifier}' cannot specify both 'token_command' and 'credentials'/'token'."
+            )
+        command = parse_token_command(details['token_command'], f"Gmail target '{identifier}'")
+        return CommandCredentials(identifier=identifier, command=command)
+
+    if not has_creds:
+        raise ConfigurationError(f"Gmail target '{identifier}' must specify either 'credentials' or 'token_command'.")
 
     creds_raw = details['credentials']
     if not isinstance(creds_raw, str):
diff --git a/src/korgalore/google_creds.py b/src/korgalore/google_creds.py
index f1963bb..0818244 100644
--- a/src/korgalore/google_creds.py
+++ b/src/korgalore/google_creds.py
@@ -1,7 +1,9 @@
-"""Google credential providers for korgalore."""
+"""Google OAuth2 and command credential providers for korgalore."""
 
 import logging
 import os
+import shlex
+import subprocess
 from collections.abc import Sequence
 from pathlib import Path
 from typing import Any
@@ -13,7 +15,7 @@ from google.auth.transport.requests import Request
 from google.oauth2.credentials import Credentials
 from google_auth_oauthlib.flow import InstalledAppFlow  # type: ignore[import-untyped]
 
-from korgalore import AuthenticationError, ConfigurationError
+from korgalore import AuthenticationError, ConfigurationError, RemoteError
 
 logger = logging.getLogger('korgalore')
 
@@ -227,3 +229,108 @@ class OAuthFileCredentials(BaseCredentials):
             ) from e
         self._save_token()
         self.needs_auth = False
+
+
+def parse_token_command(command: Any, context: str) -> list[str]:
+    """Validate and parse a ``token_command`` value into an argument vector.
+
+    Args:
+        command: Raw configuration value expected to be a non-empty command string.
+        context: Human-readable caller label for error messages.
+
+    Returns:
+        Argument vector with tilde and environment variables expanded.
+
+    Raises:
+        ConfigurationError: If ``command`` is not a string, has invalid shell
+                            quoting, or splits into an empty argument list.
+    """
+    if not isinstance(command, str):
+        raise ConfigurationError(f"{context}: 'token_command' must be a string.")
+    try:
+        raw_args = shlex.split(command)
+    except ValueError as e:
+        raise ConfigurationError(f"Invalid 'token_command' syntax for {context}: {e}") from e
+    if not raw_args:
+        raise ConfigurationError(f"{context}: 'token_command' cannot be empty.")
+    return [os.path.expanduser(os.path.expandvars(arg)) for arg in raw_args]  # noqa: PTH111
+
+
+def run_token_command(command: Sequence[str], context: str, timeout: int = 30) -> str:
+    """Execute an external token helper and return its bearer token output.
+
+    Runs ``command`` directly without a shell and verifies that standard output
+    contains a single non-empty, whitespace-free token.
+
+    Args:
+        command: Argument vector passed directly to ``subprocess.run``.
+        context: Human-readable caller label for debug logs and errors.
+        timeout: Maximum execution time in seconds before failing.
+
+    Returns:
+        Stripped bearer token read from standard output.
+
+    Raises:
+        ConfigurationError: If the executable cannot be launched.
+        RemoteError: If the command times out, exits non-zero, or returns an
+                     empty or whitespace-containing token.
+    """
+    try:
+        res = subprocess.run(
+            command,
+            stdin=subprocess.DEVNULL,
+            capture_output=True,
+            text=True,
+            encoding='utf-8',
+            errors='replace',
+            timeout=timeout,
+            check=False,
+        )
+    except subprocess.TimeoutExpired as e:
+        raise RemoteError(f'token_command timed out after {timeout}s for {context}') from e
+    except OSError as e:
+        raise ConfigurationError(f'Failed to execute token_command for {context}: {e}') from e
+
+    err_msg = ''
+    if res.stderr:
+        logger.debug('token_command stderr for %s:\n%s', context, res.stderr)
+        lines = [line.strip() for line in res.stderr.splitlines() if line.strip()]
+        if lines:
+            err_msg = f': {lines[-1]}'
+
+    if res.returncode != 0:
+        raise RemoteError(f'token_command for {context} failed with exit code {res.returncode}{err_msg}')
+
+    token = res.stdout.strip()
+    if not token or any(c.isspace() for c in token):
+        raise RemoteError(f'token_command for {context} returned empty or invalid token{err_msg}')
+
+    return token
+
+
+class CommandCredentials(BaseCredentials):
+    """Credentials obtained by executing an external token command."""
+
+    def __init__(self, identifier: str, command: Sequence[str]) -> None:
+        """Initialize command-backed bearer credentials.
+
+        Args:
+            identifier: Unique identifier for the target.
+            command: Parsed command argument vector to run on refresh.
+        """
+        super().__init__()  # type: ignore[no-untyped-call]
+        self.identifier = identifier
+        self.command = list(command)
+
+    def refresh(self, request: Any = None) -> None:
+        """Run the configured token command and capture the bearer token.
+
+        Args:
+            request: Ignored; present to satisfy the ``google.auth`` interface.
+
+        Raises:
+            ConfigurationError: If the token command cannot be executed.
+            RemoteError: If the token command fails, times out, or emits invalid
+                         output.
+        """
+        self.token = run_token_command(self.command, f"target '{self.identifier}'")
diff --git a/tests/test_cli_misc.py b/tests/test_cli_misc.py
index e351b0b..6951521 100644
--- a/tests/test_cli_misc.py
+++ b/tests/test_cli_misc.py
@@ -10,12 +10,16 @@ noise into somebody's debug log.
 """
 
 import io
+import subprocess
+from unittest.mock import patch
 
 import click
 import pytest
+from click.testing import CliRunner
 
-from korgalore import format_key_for_display
-from korgalore.cli import progress_file
+from korgalore import RemoteError, format_key_for_display
+from korgalore.cli import auth, perform_yank, progress_file, yank
+from tests.digest_helpers import make_ctx
 
 
 @pytest.mark.parametrize(
@@ -75,3 +79,98 @@ def test_hidden_bar_writes_nothing_to_stdout(capsys: pytest.CaptureFixture[str])
     # stream is what keeps it off the user's terminal, so check it landed here.
     assert isinstance(stream, io.StringIO)
     assert 'Hiding' in stream.getvalue()
+
+
+def test_auth_token_command_failure_aborts_cleanly() -> None:
+    runner = CliRunner()
+    ctx_obj = {
+        'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+        'targets': {},
+    }
+    with patch('korgalore.google_creds.subprocess.run') as mock_run:
+        mock_run.return_value = subprocess.CompletedProcess(
+            args=['get-token'], returncode=1, stdout='', stderr='command failed'
+        )
+        res = runner.invoke(auth, ['test'], obj=ctx_obj)
+        assert res.exit_code == 1
+        assert res.exception is not None
+        assert isinstance(res.exception, SystemExit)
+        mock_run.assert_called_once()
+
+
+def test_auth_token_command_verifies_labels() -> None:
+    runner = CliRunner()
+    ctx_obj = {
+        'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+        'targets': {},
+    }
+    with (
+        patch('korgalore.google_creds.subprocess.run') as mock_run,
+        patch('korgalore.gmail_target.GmailTarget.list_labels') as mock_list_labels,
+        patch('korgalore.gmail_target.build'),
+    ):
+        mock_run.return_value = subprocess.CompletedProcess(
+            args=['get-token'], returncode=0, stdout='valid-token\n', stderr=''
+        )
+        res = runner.invoke(auth, ['test'], obj=ctx_obj)
+        assert res.exit_code == 0
+        mock_run.assert_called_once()
+        mock_list_labels.assert_called_once()
+
+
+def test_yank_token_command_failure_aborts_cleanly() -> None:
+    runner = CliRunner()
+    ctx_obj = {
+        'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+        'targets': {},
+        'hide_bar': True,
+    }
+    with (
+        patch('korgalore.google_creds.subprocess.run') as mock_run,
+        patch('korgalore.cli.get_lore_node') as mock_node,
+    ):
+        mock_run.return_value = subprocess.CompletedProcess(
+            args=['get-token'], returncode=1, stdout='', stderr='command failed'
+        )
+        mock_node.return_value.get_mbox_by_msgid.return_value = b'From mboxrd@z Thu Jan 1 00:00:00 1970\n\n'
+        res = runner.invoke(yank, ['--target', 'test', '--thread', 'some-id'], obj=ctx_obj)
+        assert res.exit_code == 1
+        assert res.exception is not None
+        assert isinstance(res.exception, SystemExit)
+        mock_run.assert_called_once()
+
+
+def test_yank_single_message_command_oserror_aborts_cleanly() -> None:
+    runner = CliRunner()
+    ctx_obj = {
+        'config': {'targets': {'test': {'type': 'gmail', 'token_command': '/nonexistent/get-token'}}},
+        'targets': {},
+        'hide_bar': True,
+    }
+    with (
+        patch('korgalore.google_creds.subprocess.run') as mock_run,
+        patch('korgalore.cli.get_lore_node') as mock_node,
+    ):
+        mock_run.side_effect = FileNotFoundError('No such file')
+        mock_node.return_value.get_message_by_msgid.return_value = b'From: a@b\nSubject: x\n\nBody\n'
+        res = runner.invoke(yank, ['--target', 'test', 'some-msgid'], obj=ctx_obj)
+        assert res.exit_code == 1
+        assert res.exception is not None
+        assert isinstance(res.exception, SystemExit)
+        mock_run.assert_called_once()
+
+
+def test_perform_yank_connection_failure_raises_remote_error() -> None:
+    ctx = make_ctx(
+        {
+            'config': {'targets': {'test': {'type': 'gmail', 'token_command': 'get-token'}}},
+            'targets': {},
+            'hide_bar': True,
+        }
+    )
+    with patch('korgalore.google_creds.subprocess.run') as mock_run:
+        mock_run.return_value = subprocess.CompletedProcess(
+            args=['get-token'], returncode=1, stdout='', stderr='command failed'
+        )
+        with pytest.raises(RemoteError):
+            perform_yank(ctx, 'test', 'some-msgid')
diff --git a/tests/test_gmail_target.py b/tests/test_gmail_target.py
index 9e27f1c..3c5dfb2 100644
--- a/tests/test_gmail_target.py
+++ b/tests/test_gmail_target.py
@@ -10,7 +10,7 @@ from googleapiclient.errors import HttpError  # type: ignore[import-untyped]
 
 from korgalore import AuthenticationError, ConfigurationError, RemoteError
 from korgalore.gmail_target import SCOPES, GmailTarget, make_gmail_credentials
-from korgalore.google_creds import OAuthFileCredentials
+from korgalore.google_creds import CommandCredentials, OAuthFileCredentials
 
 
 def make_target() -> tuple[GmailTarget, MagicMock]:
@@ -300,18 +300,43 @@ class TestGmailTargetImportMessage:
 class TestMakeGmailCredentials:
     """Tests for make_gmail_credentials configuration factory."""
 
-    def test_no_credentials_raises(self) -> None:
-        """Omitting credentials raises ConfigurationError."""
-        with pytest.raises(ConfigurationError, match='No credentials file specified'):
+    def test_no_credentials_or_token_command_raises(self) -> None:
+        """Must specify either credentials or token_command."""
+        with pytest.raises(ConfigurationError, match='must specify either'):
             make_gmail_credentials('test', {})
 
+    @pytest.mark.parametrize(
+        'details',
+        [
+            {'credentials': '/tmp/creds.json', 'token_command': 'echo token'},
+            {'token': '/tmp/token.json', 'token_command': 'echo token'},
+        ],
+        ids=['credentials-and-command', 'token-and-command'],
+    )
+    def test_mutually_exclusive_token_command_options_raise(self, details: dict[str, object]) -> None:
+        """Specifying token_command alongside credentials or token raises ConfigurationError."""
+        with pytest.raises(ConfigurationError, match='cannot specify both'):
+            make_gmail_credentials('test', details)
+
+    def test_token_command_invalid_syntax_raises(self) -> None:
+        """Unclosed quotes in token_command raise ConfigurationError."""
+        with pytest.raises(ConfigurationError, match='syntax'):
+            make_gmail_credentials('test', {'token_command': 'unclosed "quote'})
+
+    def test_token_command_returns_command_credentials(self) -> None:
+        """Valid token_command builds CommandCredentials without running interactive OAuth."""
+        creds = make_gmail_credentials('test', {'token_command': 'get-token --profile work'})
+        assert isinstance(creds, CommandCredentials)
+        assert creds.command == ['get-token', '--profile', 'work']
+
     @pytest.mark.parametrize(
         ('details', 'expected_match'),
         [
             ({'credentials': 123}, "'credentials' must be a string"),
             ({'credentials': '/tmp/creds.json', 'token': 456}, "'token' must be a string"),
+            ({'token_command': 789}, "'token_command' must be a string"),
         ],
-        ids=['non-str-credentials', 'non-str-token'],
+        ids=['non-str-credentials', 'non-str-token', 'non-str-token-command'],
     )
     def test_invalid_option_types_raise(self, details: dict[str, object], expected_match: str) -> None:
         """Non-string configuration values raise ConfigurationError."""
diff --git a/tests/test_google_creds.py b/tests/test_google_creds.py
index 4ac0a3d..f392a36 100644
--- a/tests/test_google_creds.py
+++ b/tests/test_google_creds.py
@@ -1,13 +1,19 @@
-"""Tests for Google OAuth2 credential providers."""
+"""Tests for Google OAuth2 and token_command credential providers."""
 
+import subprocess
 from pathlib import Path
 from unittest.mock import MagicMock, patch
 
 import pytest
 from google.auth.exceptions import RefreshError
 
-from korgalore import AuthenticationError, ConfigurationError
-from korgalore.google_creds import OAuthFileCredentials
+from korgalore import AuthenticationError, ConfigurationError, RemoteError
+from korgalore.google_creds import (
+    CommandCredentials,
+    OAuthFileCredentials,
+    parse_token_command,
+    run_token_command,
+)
 
 TEST_SCOPES = ['https://example.invalid/scope.a', 'https://example.invalid/scope.b']
 
@@ -152,3 +158,105 @@ class TestOAuthFileCredentials:
         provider = OAuthFileCredentials('test', str(creds), token, scopes=TEST_SCOPES, interactive=False)
         assert provider.needs_auth
         assert not provider.valid
+
+
+class TestTokenCommandHelpers:
+    """Tests for target-agnostic parse_token_command and run_token_command helpers."""
+
+    def test_parse_expands_env_and_home(self, tmp_path: Path) -> None:
+        """Splits quoted args and expands environment variables and tildes."""
+        with patch.dict('os.environ', {'HOME': str(tmp_path), 'HELPER_ARG': 'work account'}):
+            args = parse_token_command('~/bin/get-token --profile "$HELPER_ARG"', "target 'test'")
+
+        assert args == [f'{tmp_path}/bin/get-token', '--profile', 'work account']
+
+    @pytest.mark.parametrize(
+        ('value', 'expected_match'),
+        [
+            (123, 'must be a string'),
+            ('   ', 'cannot be empty'),
+            ('unclosed "quote', 'syntax'),
+        ],
+        ids=['non-string', 'whitespace-only', 'bad-quoting'],
+    )
+    def test_parse_invalid_values_raise(self, value: object, expected_match: str) -> None:
+        """Invalid command strings raise ConfigurationError."""
+        with pytest.raises(ConfigurationError, match=expected_match):
+            parse_token_command(value, "target 'test'")
+
+    @patch('korgalore.google_creds.subprocess.run')
+    def test_run_returns_stripped_token(self, mock_run: MagicMock) -> None:
+        """Executes command without a shell and returns the stripped bearer token."""
+        mock_run.return_value = subprocess.CompletedProcess(
+            args=['get-token'], returncode=0, stdout='  valid-bearer-token\n', stderr=''
+        )
+
+        assert run_token_command(['get-token'], "target 'test'") == 'valid-bearer-token'
+        mock_run.assert_called_once_with(
+            ['get-token'],
+            stdin=subprocess.DEVNULL,
+            capture_output=True,
+            text=True,
+            encoding='utf-8',
+            errors='replace',
+            timeout=30,
+            check=False,
+        )
+
+    @patch('korgalore.google_creds.subprocess.run')
+    def test_run_nonzero_exit_raises_remote_error(self, mock_run: MagicMock) -> None:
+        """Non-zero exit raises RemoteError including the last non-empty stderr line."""
+        mock_run.return_value = subprocess.CompletedProcess(
+            args=['get-token'], returncode=1, stdout='', stderr='ignored line\nauth failed\n'
+        )
+
+        with pytest.raises(RemoteError, match='auth failed'):
+            run_token_command(['get-token'], "target 'test'")
+
+    @pytest.mark.parametrize(
+        'stdout',
+        ['   \n', 'multi word token\n'],
+        ids=['empty', 'embedded-whitespace'],
+    )
+    @patch('korgalore.google_creds.subprocess.run')
+    def test_run_invalid_output_raises_remote_error(self, mock_run: MagicMock, stdout: str) -> None:
+        """Empty or whitespace-containing stdout raises RemoteError."""
+        mock_run.return_value = subprocess.CompletedProcess(args=['get-token'], returncode=0, stdout=stdout, stderr='')
+
+        with pytest.raises(RemoteError, match='empty or invalid'):
+            run_token_command(['get-token'], "target 'test'")
+
+    @patch('korgalore.google_creds.subprocess.run')
+    def test_run_timeout_raises_remote_error(self, mock_run: MagicMock) -> None:
+        """Subprocess timeout raises RemoteError."""
+        mock_run.side_effect = subprocess.TimeoutExpired(cmd='get-token', timeout=30)
+
+        with pytest.raises(RemoteError, match='timed out after 30s'):
+            run_token_command(['get-token'], "target 'test'")
+
+    @patch('korgalore.google_creds.subprocess.run')
+    def test_run_oserror_raises_configuration_error(self, mock_run: MagicMock) -> None:
+        """Missing executable raises ConfigurationError."""
+        mock_run.side_effect = FileNotFoundError('No such file or directory')
+
+        with pytest.raises(ConfigurationError, match='Failed to execute'):
+            run_token_command(['nonexistent-cmd'], "target 'test'")
+
+
+class TestCommandCredentials:
+    """Tests for CommandCredentials."""
+
+    @patch('korgalore.google_creds.subprocess.run')
+    def test_refresh_sets_token_and_marks_valid(self, mock_run: MagicMock) -> None:
+        """CommandCredentials.refresh() invokes run_token_command and populates token."""
+        mock_run.return_value = subprocess.CompletedProcess(
+            args=['get-token'], returncode=0, stdout='valid-bearer-token\n', stderr=''
+        )
+
+        creds = CommandCredentials('test', ['get-token'])
+        assert not creds.valid
+
+        creds.refresh(None)
+
+        assert creds.valid
+        assert creds.token == 'valid-bearer-token'

-- 
2.56.0.542.g3ef89b7020-goog


^ permalink raw reply related	[flat|nested] 4+ messages in thread

* Re: [PATCH kgl 0/2] Add token_command support for Gmail targets
  2026-11-11  6:59 [PATCH kgl 0/2] Add token_command support for Gmail targets Dmitry Torokhov
  2026-11-11  6:59 ` [PATCH kgl 1/2] Decouple GmailTarget from OAuth file credential management Dmitry Torokhov
  2026-11-11  6:59 ` [PATCH kgl 2/2] Add token_command support for Gmail targets Dmitry Torokhov
@ 2026-11-12 19:58 ` Konstantin Ryabitsev
  2 siblings, 0 replies; 4+ messages in thread
From: Konstantin Ryabitsev @ 2026-11-12 19:58 UTC (permalink / raw)
  To: Kernel.org Tools, Dmitry Torokhov


On Tue, 10 Nov 2026 22:59:17 -0800, Dmitry Torokhov wrote:
> Add token_command support for Gmail targets
> 
> This series adds support for obtaining Gmail OAuth2 bearer tokens from
> an external command via a new token_command setting on Gmail targets
> (similar to passwordeval in msmtp or PassCmd in mbsync). This lets
> korgalore share an encrypted credential store or OAuth2 token helper
> (such as oama, git-credential helpers, or custom keyring/GPG scripts)
> with other mail tools without managing separate Google OAuth client
> secrets, running another browser login flow, or keeping an unencrypted
> token JSON file on disk.
> 
> [...]

Applied, thanks!

[1/2] Decouple GmailTarget from OAuth file credential management
      https://git.kernel.org/pub/scm/utils/korgalore/korgalore.git/commit/?id=1d5b42b214461500a8f3e00a9bac7fa8e0c04237
[2/2] Add token_command support for Gmail targets
      https://git.kernel.org/pub/scm/utils/korgalore/korgalore.git/commit/?id=c7bb04ec81b8b172b388345ceb48270dd2962c38

Best regards,
-- 
Konstantin Ryabitsev <konstantin@linuxfoundation.org>


^ permalink raw reply	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2026-11-12 19:58 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-11-11  6:59 [PATCH kgl 0/2] Add token_command support for Gmail targets Dmitry Torokhov
2026-11-11  6:59 ` [PATCH kgl 1/2] Decouple GmailTarget from OAuth file credential management Dmitry Torokhov
2026-11-11  6:59 ` [PATCH kgl 2/2] Add token_command support for Gmail targets Dmitry Torokhov
2026-11-12 19:58 ` [PATCH kgl 0/2] " Konstantin Ryabitsev

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.