gmail-streamer 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gmail_streamer/__init__.py +1 -0
- gmail_streamer/_build_info.py +1 -0
- gmail_streamer/auth.py +33 -0
- gmail_streamer/cli.py +145 -0
- gmail_streamer/config.py +28 -0
- gmail_streamer/gmail_client.py +68 -0
- gmail_streamer/paths.py +42 -0
- gmail_streamer/storage.py +50 -0
- gmail_streamer-0.2.2.dist-info/METADATA +132 -0
- gmail_streamer-0.2.2.dist-info/RECORD +13 -0
- gmail_streamer-0.2.2.dist-info/WHEEL +4 -0
- gmail_streamer-0.2.2.dist-info/entry_points.txt +2 -0
- gmail_streamer-0.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
GIT_HASH = "unknown"
|
gmail_streamer/auth.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from google.auth.transport.requests import Request
|
|
4
|
+
from google.oauth2.credentials import Credentials
|
|
5
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
6
|
+
from googleapiclient.discovery import build
|
|
7
|
+
|
|
8
|
+
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_gmail_service(profile_dir: Path):
|
|
12
|
+
creds_path = profile_dir / "credentials.json"
|
|
13
|
+
token_path = profile_dir / "token.json"
|
|
14
|
+
|
|
15
|
+
creds = None
|
|
16
|
+
if token_path.exists():
|
|
17
|
+
creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
|
|
18
|
+
|
|
19
|
+
if not creds or not creds.valid:
|
|
20
|
+
if creds and creds.expired and creds.refresh_token:
|
|
21
|
+
creds.refresh(Request())
|
|
22
|
+
else:
|
|
23
|
+
if not creds_path.exists():
|
|
24
|
+
raise FileNotFoundError(
|
|
25
|
+
f"OAuth credentials not found: {creds_path}\n"
|
|
26
|
+
"Download from Google Cloud Console and place in profile directory."
|
|
27
|
+
)
|
|
28
|
+
flow = InstalledAppFlow.from_client_secrets_file(str(creds_path), SCOPES)
|
|
29
|
+
creds = flow.run_local_server(port=0)
|
|
30
|
+
with open(token_path, "w") as f:
|
|
31
|
+
f.write(creds.to_json())
|
|
32
|
+
|
|
33
|
+
return build("gmail", "v1", credentials=creds)
|
gmail_streamer/cli.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from gmail_streamer.auth import get_gmail_service
|
|
7
|
+
from gmail_streamer.config import load_config
|
|
8
|
+
from gmail_streamer.gmail_client import (
|
|
9
|
+
fetch_attachments,
|
|
10
|
+
fetch_message_metadata,
|
|
11
|
+
fetch_raw_message,
|
|
12
|
+
search_messages,
|
|
13
|
+
)
|
|
14
|
+
from gmail_streamer.paths import get_profiles_dir, list_profiles, resolve_profile
|
|
15
|
+
from gmail_streamer.storage import save_attachments, save_eml, save_metadata, scan_downloaded_metadata
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
@click.option(
|
|
20
|
+
"--profile-dir",
|
|
21
|
+
envvar="GMAIL_STREAMER_PROFILE_DIR",
|
|
22
|
+
default=None,
|
|
23
|
+
type=click.Path(file_okay=False),
|
|
24
|
+
help="Override profiles directory.",
|
|
25
|
+
)
|
|
26
|
+
@click.pass_context
|
|
27
|
+
def main(ctx, profile_dir):
|
|
28
|
+
"""Download Gmail messages matching configurable filters."""
|
|
29
|
+
ctx.ensure_object(dict)
|
|
30
|
+
ctx.obj["profiles_dir"] = get_profiles_dir(profile_dir)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@main.command()
|
|
34
|
+
@click.argument("profile")
|
|
35
|
+
@click.pass_context
|
|
36
|
+
def run(ctx, profile):
|
|
37
|
+
"""Download messages for a profile."""
|
|
38
|
+
profiles_dir = ctx.obj["profiles_dir"]
|
|
39
|
+
profile_path = resolve_profile(profile, profiles_dir)
|
|
40
|
+
|
|
41
|
+
if not profile_path.is_dir():
|
|
42
|
+
raise click.ClickException(f"Profile directory not found: {profile_path}")
|
|
43
|
+
|
|
44
|
+
config = load_config(profile_path)
|
|
45
|
+
target = Path(config.target_directory)
|
|
46
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
click.echo(f"Authenticating profile '{profile_path.name}'...")
|
|
49
|
+
service = get_gmail_service(profile_path)
|
|
50
|
+
|
|
51
|
+
downloaded_ids, most_recent_date = scan_downloaded_metadata(target)
|
|
52
|
+
if most_recent_date:
|
|
53
|
+
click.echo(f"Resuming from {most_recent_date} ({len(downloaded_ids)} already downloaded)")
|
|
54
|
+
|
|
55
|
+
click.echo(f"Searching: {config.filter}")
|
|
56
|
+
msg_ids = search_messages(service, config.filter, after_date=most_recent_date)
|
|
57
|
+
new_ids = [mid for mid in msg_ids if mid not in downloaded_ids]
|
|
58
|
+
click.echo(f"Found {len(msg_ids)} messages, {len(new_ids)} new.")
|
|
59
|
+
|
|
60
|
+
for i, msg_id in enumerate(new_ids, 1):
|
|
61
|
+
click.echo(f"[{i}/{len(new_ids)}] Downloading {msg_id}...")
|
|
62
|
+
|
|
63
|
+
metadata = fetch_message_metadata(service, msg_id)
|
|
64
|
+
date = metadata["date"]
|
|
65
|
+
|
|
66
|
+
if config.mode == "full":
|
|
67
|
+
raw = fetch_raw_message(service, msg_id)
|
|
68
|
+
save_eml(target, msg_id, date, raw)
|
|
69
|
+
attachments = fetch_attachments(service, msg_id)
|
|
70
|
+
if attachments:
|
|
71
|
+
save_attachments(target, msg_id, date, attachments)
|
|
72
|
+
|
|
73
|
+
elif config.mode == "attachments_only":
|
|
74
|
+
attachments = fetch_attachments(service, msg_id)
|
|
75
|
+
if attachments:
|
|
76
|
+
save_attachments(target, msg_id, date, attachments)
|
|
77
|
+
else:
|
|
78
|
+
click.echo(f" No attachments for {msg_id}")
|
|
79
|
+
|
|
80
|
+
save_metadata(target, msg_id, date, metadata)
|
|
81
|
+
|
|
82
|
+
click.echo("Done.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@main.group("profiles")
|
|
86
|
+
def profiles_group():
|
|
87
|
+
"""Manage profiles."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@profiles_group.command("list")
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def profiles_list(ctx):
|
|
93
|
+
"""List available profiles."""
|
|
94
|
+
profiles_dir = ctx.obj["profiles_dir"]
|
|
95
|
+
names = list_profiles(profiles_dir)
|
|
96
|
+
if not names:
|
|
97
|
+
click.echo(f"No profiles found in {profiles_dir}")
|
|
98
|
+
return
|
|
99
|
+
click.echo(f"Profiles in {profiles_dir}:\n")
|
|
100
|
+
for name in names:
|
|
101
|
+
click.echo(f" {name}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@profiles_group.command("init")
|
|
105
|
+
@click.argument("name")
|
|
106
|
+
@click.pass_context
|
|
107
|
+
def profiles_init(ctx, name):
|
|
108
|
+
"""Scaffold a new profile directory with a template config."""
|
|
109
|
+
profiles_dir = ctx.obj["profiles_dir"]
|
|
110
|
+
profile_dir = profiles_dir / name
|
|
111
|
+
|
|
112
|
+
if profile_dir.exists():
|
|
113
|
+
raise click.ClickException(f"Profile already exists: {profile_dir}")
|
|
114
|
+
|
|
115
|
+
profile_dir.mkdir(parents=True)
|
|
116
|
+
config = {
|
|
117
|
+
"filter": 'from:example@gmail.com has:attachment',
|
|
118
|
+
"target_directory": "./downloads",
|
|
119
|
+
"mode": "full",
|
|
120
|
+
}
|
|
121
|
+
(profile_dir / "config.yaml").write_text(yaml.dump(config, default_flow_style=False))
|
|
122
|
+
click.echo(f"Created profile at {profile_dir}")
|
|
123
|
+
click.echo("Next steps:")
|
|
124
|
+
click.echo(f" 1. Edit {profile_dir / 'config.yaml'} with your filter")
|
|
125
|
+
click.echo(f" 2. Copy your credentials.json into {profile_dir}")
|
|
126
|
+
click.echo(f" 3. Run: gmail-streamer run {name}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@profiles_group.command("show")
|
|
130
|
+
@click.argument("name")
|
|
131
|
+
@click.pass_context
|
|
132
|
+
def profiles_show(ctx, name):
|
|
133
|
+
"""Show a profile's configuration."""
|
|
134
|
+
profiles_dir = ctx.obj["profiles_dir"]
|
|
135
|
+
profile_path = resolve_profile(name, profiles_dir)
|
|
136
|
+
|
|
137
|
+
if not profile_path.is_dir():
|
|
138
|
+
raise click.ClickException(f"Profile not found: {profile_path}")
|
|
139
|
+
|
|
140
|
+
config_file = profile_path / "config.yaml"
|
|
141
|
+
if not config_file.exists():
|
|
142
|
+
raise click.ClickException(f"No config.yaml in {profile_path}")
|
|
143
|
+
|
|
144
|
+
click.echo(f"Profile: {profile_path.name} ({profile_path})\n")
|
|
145
|
+
click.echo(config_file.read_text())
|
gmail_streamer/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ProfileConfig:
|
|
9
|
+
filter: str
|
|
10
|
+
target_directory: str
|
|
11
|
+
mode: str = "full" # "full" or "attachments_only"
|
|
12
|
+
|
|
13
|
+
def __post_init__(self):
|
|
14
|
+
if self.mode not in ("full", "attachments_only"):
|
|
15
|
+
raise ValueError(f"Invalid mode: {self.mode!r}. Must be 'full' or 'attachments_only'.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_config(profile_dir: Path) -> ProfileConfig:
|
|
19
|
+
config_path = profile_dir / "config.yaml"
|
|
20
|
+
if not config_path.exists():
|
|
21
|
+
raise FileNotFoundError(f"Config not found: {config_path}")
|
|
22
|
+
with open(config_path) as f:
|
|
23
|
+
data = yaml.safe_load(f)
|
|
24
|
+
config = ProfileConfig(**data)
|
|
25
|
+
target = Path(config.target_directory)
|
|
26
|
+
if not target.is_absolute():
|
|
27
|
+
config.target_directory = str((profile_dir / target).resolve())
|
|
28
|
+
return config
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def search_messages(service, query: str, after_date: str | None = None) -> list[str]:
|
|
6
|
+
"""Return all message IDs matching the query.
|
|
7
|
+
|
|
8
|
+
If after_date (YYYY-MM-DD) is provided, appends 'after:{date}' to narrow results.
|
|
9
|
+
"""
|
|
10
|
+
if after_date:
|
|
11
|
+
query = f"{query} after:{after_date}"
|
|
12
|
+
ids = []
|
|
13
|
+
request = service.users().messages().list(userId="me", q=query)
|
|
14
|
+
while request:
|
|
15
|
+
response = request.execute()
|
|
16
|
+
for msg in response.get("messages", []):
|
|
17
|
+
ids.append(msg["id"])
|
|
18
|
+
request = service.users().messages().list_next(request, response)
|
|
19
|
+
return ids
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch_raw_message(service, msg_id: str) -> bytes:
|
|
23
|
+
"""Fetch the full RFC 2822 message as bytes."""
|
|
24
|
+
msg = service.users().messages().get(userId="me", id=msg_id, format="raw").execute()
|
|
25
|
+
return base64.urlsafe_b64decode(msg["raw"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def fetch_message_metadata(service, msg_id: str) -> dict:
|
|
29
|
+
"""Fetch message metadata and return a dict with key fields."""
|
|
30
|
+
msg = service.users().messages().get(
|
|
31
|
+
userId="me", id=msg_id, format="metadata",
|
|
32
|
+
metadataHeaders=["From", "To", "Subject", "Date"],
|
|
33
|
+
).execute()
|
|
34
|
+
|
|
35
|
+
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
|
|
36
|
+
internal_ts = int(msg.get("internalDate", "0")) / 1000
|
|
37
|
+
internal_date = datetime.fromtimestamp(internal_ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
"id": msg_id,
|
|
41
|
+
"date": internal_date,
|
|
42
|
+
"subject": headers.get("Subject", ""),
|
|
43
|
+
"from": headers.get("From", ""),
|
|
44
|
+
"to": headers.get("To", ""),
|
|
45
|
+
"snippet": msg.get("snippet", ""),
|
|
46
|
+
"label_ids": msg.get("labelIds", []),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_attachments(service, msg_id: str) -> list[dict]:
|
|
51
|
+
"""Return list of {filename, data} for each attachment."""
|
|
52
|
+
msg = service.users().messages().get(userId="me", id=msg_id).execute()
|
|
53
|
+
attachments = []
|
|
54
|
+
for part in msg.get("payload", {}).get("parts", []):
|
|
55
|
+
filename = part.get("filename")
|
|
56
|
+
body = part.get("body", {})
|
|
57
|
+
attachment_id = body.get("attachmentId")
|
|
58
|
+
if filename and attachment_id:
|
|
59
|
+
att = (
|
|
60
|
+
service.users()
|
|
61
|
+
.messages()
|
|
62
|
+
.attachments()
|
|
63
|
+
.get(userId="me", messageId=msg_id, id=attachment_id)
|
|
64
|
+
.execute()
|
|
65
|
+
)
|
|
66
|
+
data = base64.urlsafe_b64decode(att["data"])
|
|
67
|
+
attachments.append({"filename": filename, "data": data})
|
|
68
|
+
return attachments
|
gmail_streamer/paths.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
DEFAULT_BASE_DIR = Path.home() / ".gmail-streamer"
|
|
4
|
+
DEFAULT_PROFILES_DIR = DEFAULT_BASE_DIR / "profiles"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_profiles_dir(override: str | None = None) -> Path:
|
|
8
|
+
"""Return the active profiles directory.
|
|
9
|
+
|
|
10
|
+
Priority:
|
|
11
|
+
1. Explicit override (--profile-dir flag)
|
|
12
|
+
2. ./profiles/ in CWD (if it exists)
|
|
13
|
+
3. ~/.gmail-streamer/profiles/ (fallback)
|
|
14
|
+
"""
|
|
15
|
+
if override:
|
|
16
|
+
return Path(override).resolve()
|
|
17
|
+
cwd_profiles = Path.cwd() / "profiles"
|
|
18
|
+
if cwd_profiles.is_dir():
|
|
19
|
+
return cwd_profiles.resolve()
|
|
20
|
+
return DEFAULT_PROFILES_DIR
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_profile(name: str, profiles_dir: Path) -> Path:
|
|
24
|
+
"""Resolve a profile name or path to a directory.
|
|
25
|
+
|
|
26
|
+
If `name` is an existing directory, use it directly (backward compat).
|
|
27
|
+
Otherwise look it up as {profiles_dir}/{name}/.
|
|
28
|
+
"""
|
|
29
|
+
candidate = Path(name)
|
|
30
|
+
if candidate.is_dir():
|
|
31
|
+
return candidate.resolve()
|
|
32
|
+
return profiles_dir / name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_profiles(profiles_dir: Path) -> list[str]:
|
|
36
|
+
"""Return profile names (subdirectories containing config.yaml)."""
|
|
37
|
+
if not profiles_dir.is_dir():
|
|
38
|
+
return []
|
|
39
|
+
return sorted(
|
|
40
|
+
d.name for d in profiles_dir.iterdir()
|
|
41
|
+
if d.is_dir() and (d / "config.yaml").exists()
|
|
42
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _short_id(msg_id: str) -> str:
|
|
6
|
+
return msg_id[:8]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def save_eml(target_dir: Path, msg_id: str, date: str, raw: bytes):
|
|
10
|
+
"""Save .eml file with date and short ID."""
|
|
11
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
(target_dir / f"{date} - {_short_id(msg_id)}.eml").write_bytes(raw)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def save_metadata(target_dir: Path, msg_id: str, date: str, metadata: dict):
|
|
16
|
+
"""Save metadata JSON with date and short ID."""
|
|
17
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
path = target_dir / f"{date} - {_short_id(msg_id)}.json"
|
|
19
|
+
path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def save_attachments(target_dir: Path, msg_id: str, date: str, attachments: list[dict]):
|
|
23
|
+
"""Save attachments flat in target_dir with date and short ID."""
|
|
24
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
sid = _short_id(msg_id)
|
|
26
|
+
for att in attachments:
|
|
27
|
+
(target_dir / f"{date} - {sid} - {att['filename']}").write_bytes(att["data"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def scan_downloaded_metadata(target_dir: Path) -> tuple[set[str], str | None]:
|
|
31
|
+
"""Scan metadata JSON files to derive downloaded IDs and most recent date.
|
|
32
|
+
|
|
33
|
+
Returns (downloaded_ids, most_recent_date_or_none).
|
|
34
|
+
"""
|
|
35
|
+
downloaded_ids: set[str] = set()
|
|
36
|
+
most_recent_date: str | None = None
|
|
37
|
+
|
|
38
|
+
for meta_path in target_dir.glob("* - *.json"):
|
|
39
|
+
try:
|
|
40
|
+
meta = json.loads(meta_path.read_text())
|
|
41
|
+
except (json.JSONDecodeError, OSError):
|
|
42
|
+
continue
|
|
43
|
+
msg_id = meta.get("id")
|
|
44
|
+
date = meta.get("date")
|
|
45
|
+
if msg_id:
|
|
46
|
+
downloaded_ids.add(msg_id)
|
|
47
|
+
if date and (most_recent_date is None or date > most_recent_date):
|
|
48
|
+
most_recent_date = date
|
|
49
|
+
|
|
50
|
+
return downloaded_ids, most_recent_date
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gmail-streamer
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: CLI tool to download Gmail messages matching configurable filters via OAuth2
|
|
5
|
+
Project-URL: Homepage, https://github.com/tsilva/gmail-streamer
|
|
6
|
+
Project-URL: Repository, https://github.com/tsilva/gmail-streamer
|
|
7
|
+
Project-URL: Issues, https://github.com/tsilva/gmail-streamer/issues
|
|
8
|
+
Author: Tiago Silva
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: attachments,backup,cli,download,email,gmail,google,oauth2
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Email
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: click
|
|
23
|
+
Requires-Dist: google-api-python-client
|
|
24
|
+
Requires-Dist: google-auth-httplib2
|
|
25
|
+
Requires-Dist: google-auth-oauthlib
|
|
26
|
+
Requires-Dist: pyyaml
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
<div align="center">
|
|
30
|
+
<img src="logo.png" alt="gmail-streamer" width="512"/>
|
|
31
|
+
|
|
32
|
+
[](https://python.org)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
**📧 Download Gmail messages matching your filters to local files 📥**
|
|
36
|
+
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
## ✨ Features
|
|
40
|
+
|
|
41
|
+
- **🗂️ Profile-based configuration** — run multiple independent download profiles, each with its own filters, credentials, and output directory
|
|
42
|
+
- **🔐 OAuth2 authentication** — secure Google sign-in with automatic token caching
|
|
43
|
+
- **📧 Full message download** — save complete `.eml` files for archival
|
|
44
|
+
- **📎 Attachments-only mode** — grab just the attachments, skip the rest
|
|
45
|
+
- **🧠 Incremental downloads** — remembers what's already been downloaded, no duplicates across runs
|
|
46
|
+
- **🔍 Gmail search filters** — use any Gmail search query (`from:`, `has:attachment`, `after:`, label filters, etc.)
|
|
47
|
+
- **🏠 Works from anywhere** — install globally with `uv` and run from any directory
|
|
48
|
+
|
|
49
|
+
## 🚀 Quick Start
|
|
50
|
+
|
|
51
|
+
### 1. Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/tsilva/gmail-streamer.git
|
|
55
|
+
cd gmail-streamer
|
|
56
|
+
uv tool install . --force --no-cache
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Create a profile
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
gmail-streamer profiles init my-profile
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This creates `~/.gmail-streamer/profiles/my-profile/` with a template `config.yaml`.
|
|
66
|
+
|
|
67
|
+
### 3. Add your Gmail OAuth credentials
|
|
68
|
+
|
|
69
|
+
Place your `credentials.json` (from [Google Cloud Console](https://console.cloud.google.com/apis/credentials)) into the profile directory.
|
|
70
|
+
|
|
71
|
+
### 4. Configure the profile
|
|
72
|
+
|
|
73
|
+
Edit `~/.gmail-streamer/profiles/my-profile/config.yaml`:
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
filter: "from:example@gmail.com has:attachment"
|
|
77
|
+
target_directory: "./downloads"
|
|
78
|
+
mode: "full" # or "attachments_only"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 5. Run
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
gmail-streamer run my-profile
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
On first run, a browser window opens for OAuth authorization. Subsequent runs reuse the cached token.
|
|
88
|
+
|
|
89
|
+
## 📁 Profile Resolution
|
|
90
|
+
|
|
91
|
+
The profiles directory is resolved in this order:
|
|
92
|
+
|
|
93
|
+
1. `--profile-dir` flag or `GMAIL_STREAMER_PROFILE_DIR` env var
|
|
94
|
+
2. `./profiles/` in the current working directory (if it exists)
|
|
95
|
+
3. `~/.gmail-streamer/profiles/` (default)
|
|
96
|
+
|
|
97
|
+
The `profile` argument can be a **name** (looked up in the profiles directory) or a **path** to an existing directory (backward compatible).
|
|
98
|
+
|
|
99
|
+
## 🛠️ CLI Reference
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
gmail-streamer run <profile> # Download messages
|
|
103
|
+
gmail-streamer --profile-dir /path run <profile> # Custom profiles directory
|
|
104
|
+
gmail-streamer profiles list # List available profiles
|
|
105
|
+
gmail-streamer profiles init <name> # Scaffold a new profile
|
|
106
|
+
gmail-streamer profiles show <name> # Show profile config
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## ⚙️ Profile Structure
|
|
110
|
+
|
|
111
|
+
Each profile lives in its own directory with:
|
|
112
|
+
|
|
113
|
+
| File | Purpose |
|
|
114
|
+
|------|---------|
|
|
115
|
+
| `config.yaml` | Filter query, target directory, download mode |
|
|
116
|
+
| `credentials.json` | OAuth client credentials (you provide this) |
|
|
117
|
+
| `token.json` | Auto-generated after first OAuth flow |
|
|
118
|
+
|
|
119
|
+
## 🏗️ Architecture
|
|
120
|
+
|
|
121
|
+
| Module | Responsibility |
|
|
122
|
+
|--------|---------------|
|
|
123
|
+
| `cli.py` | Click CLI entry point (group with `run` and `profiles` subcommands) |
|
|
124
|
+
| `paths.py` | Profile directory resolution and discovery |
|
|
125
|
+
| `config.py` | Loads and validates `config.yaml` into a `ProfileConfig` dataclass |
|
|
126
|
+
| `auth.py` | OAuth2 flow with token caching |
|
|
127
|
+
| `gmail_client.py` | Gmail API wrapper: search, fetch messages, fetch attachments |
|
|
128
|
+
| `storage.py` | Saves `.eml` files and attachments to disk |
|
|
129
|
+
|
|
130
|
+
## 📄 License
|
|
131
|
+
|
|
132
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
gmail_streamer/__init__.py,sha256=HfjVOrpTnmZ-xVFCYSVmX50EXaBQeJteUHG-PD6iQs8,22
|
|
2
|
+
gmail_streamer/auth.py,sha256=Mt3UJA4NbCrIgiKbbgODHh9HmuxVhsZPkU7tBzfr9OY,1220
|
|
3
|
+
gmail_streamer/cli.py,sha256=m-0OgJja9Fjj820yZJH80CS04bPsQ8L5lf4ExpnBuD4,4791
|
|
4
|
+
gmail_streamer/config.py,sha256=GRgsuRMQ7uNM6H-iwAbtoDE2aHIV9UY4HvJKYqMuF_w,860
|
|
5
|
+
gmail_streamer/gmail_client.py,sha256=hc0gahvw2rP3qpRlvSX3AB-2Y_2kEX_tQza9jw0j4qU,2544
|
|
6
|
+
gmail_streamer/paths.py,sha256=TYErMHc1Nh5EUImzEh2k1tw7omAnldvmMEnW71t0T3Q,1269
|
|
7
|
+
gmail_streamer/storage.py,sha256=uzUDIXBgQmXd111c-zE7usxZ7Vqfe0KFlpLcDaXMqBI,1773
|
|
8
|
+
gmail_streamer/_build_info.py,sha256=u78KhLyvgyWjUoCs30k7OxpPsuL-xdvmXoUoRn3hk4Q,21
|
|
9
|
+
gmail_streamer-0.2.2.dist-info/METADATA,sha256=3i-EMPwL1_5PYpC8RH74G3AZ3W1vuHoGfA19Gk_UhAg,4689
|
|
10
|
+
gmail_streamer-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
11
|
+
gmail_streamer-0.2.2.dist-info/entry_points.txt,sha256=hfb9lqqUjanoVn2vwdhNefsoZn8c5tYMdNT6Jt4fQFo,59
|
|
12
|
+
gmail_streamer-0.2.2.dist-info/licenses/LICENSE,sha256=gTrdDdqFDu7VtWezebCYIHmebmc-XTVxqWTZGKvDux0,1068
|
|
13
|
+
gmail_streamer-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tiago Silva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|