inboxscan 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sravya Vedantham
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.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: inboxscan
3
+ Version: 0.1.0
4
+ Summary: Find every subscription hiding in your email — zero credentials stored, nothing leaves your machine.
5
+ Author: Sravya Vedantham
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ License-File: LICENSE
9
+ Requires-Dist: typer>=0.9.0
10
+ Requires-Dist: rich>=13.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
13
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
14
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
15
+ Requires-Dist: black>=23.0.0; extra == "dev"
16
+ Requires-Dist: isort>=5.0.0; extra == "dev"
17
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
18
+ Dynamic: license-file
@@ -0,0 +1,75 @@
1
+ # inboxscan
2
+
3
+ Find every subscription hiding in your email. See exactly what you're paying for.
4
+
5
+ Zero credentials stored. Nothing leaves your machine.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install inboxscan
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # Scan one Gmail account
17
+ inboxscan run --email you@gmail.com
18
+
19
+ # Scan multiple accounts
20
+ inboxscan run --email work@gmail.com --email personal@gmail.com
21
+
22
+ # Get cancellation instructions
23
+ inboxscan cancel netflix
24
+ inboxscan cancel adobe
25
+
26
+ # Print version
27
+ inboxscan version
28
+ ```
29
+
30
+ ## Example output
31
+
32
+ ```
33
+ INBOXSCAN REPORT
34
+ ════════════════════════════════════════════════════════════
35
+ Scanned: personal@gmail.com, work@gmail.com
36
+ Found: 8 subscriptions | Total burn: $142.93/mo
37
+
38
+ [ACTIVE] Netflix $15.99/mo Mar 01 personal@gmail.com
39
+ [ACTIVE] Claude Pro $20.00/mo Mar 01 work@gmail.com
40
+ [ACTIVE] Adobe CC $54.99/mo Mar 03 work@gmail.com
41
+ [ACTIVE] GitHub Pro $4.00/mo Mar 05 work@gmail.com
42
+ [ACTIVE] Spotify $10.99/mo Mar 02 personal@gmail.com
43
+ [DORMANT] Audible $14.95/mo Nov 02 personal@gmail.com
44
+ [DORMANT] Skillshare $9.99/mo Oct 15 personal@gmail.com
45
+
46
+ ════════════════════════════════════════════════════════════
47
+ Potential savings: $24.94/mo (cancel DORMANT subscriptions)
48
+
49
+ Run 'inboxscan cancel <service>' for cancellation steps.
50
+ ```
51
+
52
+ ## Gmail App Password
53
+
54
+ 1. Go to [myaccount.google.com/security](https://myaccount.google.com/security)
55
+ 2. Enable 2-Step Verification
56
+ 3. Search "App passwords" → create one named "inboxscan"
57
+ 4. Use that 16-character password when prompted
58
+
59
+ Your password is used only for the IMAP connection and is never stored.
60
+
61
+ ## Privacy contract
62
+
63
+ - No credentials stored — app password used only during the scan session
64
+ - No network calls beyond Gmail IMAP fetch
65
+ - All processing happens locally on your machine
66
+ - Results cached in `~/.inboxscan/cache.db` (your machine only)
67
+ - Zero telemetry
68
+
69
+ ## Detects 40+ services
70
+
71
+ Netflix, Spotify, Adobe CC, GitHub Pro, Figma, Notion, Linear, Slack, Zoom, Dropbox, Claude Pro, ChatGPT Plus, Amazon Prime, Apple One, Google One, Microsoft 365, Canva Pro, Audible, Skillshare, Duolingo Plus, Grammarly, 1Password, NordVPN, Hulu, Disney+, YouTube Premium, Cursor Pro, Vercel, Railway, Supabase, Sentry, Datadog, Cloudflare, and more.
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "inboxscan"
7
+ version = "0.1.0"
8
+ description = "Find every subscription hiding in your email — zero credentials stored, nothing leaves your machine."
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Sravya Vedantham" }]
12
+ dependencies = [
13
+ "typer>=0.9.0",
14
+ "rich>=13.0.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=7.0.0",
20
+ "pytest-cov>=4.0.0",
21
+ "mypy>=1.0.0",
22
+ "black>=23.0.0",
23
+ "isort>=5.0.0",
24
+ "flake8>=6.0.0",
25
+ ]
26
+
27
+ [project.scripts]
28
+ inboxscan = "inboxscan.cli:app"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ addopts = "--tb=short"
36
+
37
+ [tool.black]
38
+ line-length = 88
39
+ target-version = ["py310"]
40
+
41
+ [tool.isort]
42
+ profile = "black"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,62 @@
1
+ import sqlite3
2
+ from datetime import date
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from inboxscan.models import ScanResult, Subscription, SubscriptionStatus
7
+
8
+ CACHE_PATH = Path.home() / ".inboxscan" / "cache.db"
9
+
10
+
11
+ def _get_conn(path: Path = CACHE_PATH) -> sqlite3.Connection:
12
+ path.parent.mkdir(exist_ok=True)
13
+ conn = sqlite3.connect(path)
14
+ conn.execute("""
15
+ CREATE TABLE IF NOT EXISTS subscriptions (
16
+ service_name TEXT,
17
+ amount REAL,
18
+ currency TEXT,
19
+ billing_frequency TEXT,
20
+ last_charge_date TEXT,
21
+ source_email TEXT,
22
+ status TEXT,
23
+ cancellation_url TEXT
24
+ )
25
+ """)
26
+ conn.commit()
27
+ return conn
28
+
29
+
30
+ def save_result(result: ScanResult, path: Path = CACHE_PATH) -> None:
31
+ conn = _get_conn(path)
32
+ conn.execute("DELETE FROM subscriptions")
33
+ for sub in result.subscriptions:
34
+ conn.execute(
35
+ "INSERT INTO subscriptions VALUES (?,?,?,?,?,?,?,?)",
36
+ (sub.service_name, sub.amount, sub.currency, sub.billing_frequency,
37
+ sub.last_charge_date.isoformat(), sub.source_email,
38
+ sub.status.value, sub.cancellation_url),
39
+ )
40
+ conn.commit()
41
+ conn.close()
42
+
43
+
44
+ def load_result(path: Path = CACHE_PATH) -> Optional[ScanResult]:
45
+ if not path.exists():
46
+ return None
47
+ conn = _get_conn(path)
48
+ rows = conn.execute("SELECT * FROM subscriptions").fetchall()
49
+ conn.close()
50
+ if not rows:
51
+ return None
52
+ subs = [
53
+ Subscription(
54
+ service_name=r[0], amount=r[1], currency=r[2],
55
+ billing_frequency=r[3],
56
+ last_charge_date=date.fromisoformat(r[4]),
57
+ source_email=r[5], status=SubscriptionStatus(r[6]),
58
+ cancellation_url=r[7],
59
+ )
60
+ for r in rows
61
+ ]
62
+ return ScanResult(subscriptions=subs)
@@ -0,0 +1,36 @@
1
+ from rich.console import Console
2
+ from inboxscan.detector import KNOWN_SERVICES
3
+
4
+ console = Console()
5
+
6
+ CANCELLATION_TEMPLATES: dict[str, str] = {
7
+ "Netflix": "Subject: Cancel My Subscription\n\nPlease cancel my Netflix subscription immediately and confirm via email.",
8
+ "Spotify": "Subject: Subscription Cancellation Request\n\nPlease cancel my Spotify Premium subscription and confirm.",
9
+ "Adobe CC": "Subject: Cancel Creative Cloud Subscription\n\nI would like to cancel my Adobe Creative Cloud subscription. Please process this immediately.",
10
+ "Audible": "Subject: Cancel Audible Membership\n\nPlease cancel my Audible membership immediately and confirm cancellation.",
11
+ "Skillshare": "Subject: Cancel Skillshare Subscription\n\nPlease cancel my Skillshare subscription effective immediately.",
12
+ }
13
+
14
+
15
+ def cancel_service(service_name: str) -> None:
16
+ matched = None
17
+ for domain, (name, url, _) in KNOWN_SERVICES.items():
18
+ if name.lower() == service_name.lower():
19
+ matched = (name, url)
20
+ break
21
+
22
+ if not matched:
23
+ console.print(f"[red]Unknown service: {service_name}[/red]")
24
+ console.print("Run [bold]inboxscan run[/bold] to see your active subscriptions.")
25
+ return
26
+
27
+ name, url = matched
28
+ console.print(f"\n[bold]Cancel {name}[/bold]")
29
+ console.print("─" * 40)
30
+ console.print(f"Cancellation page: [link={url}]{url}[/link]")
31
+
32
+ template = CANCELLATION_TEMPLATES.get(name)
33
+ if template:
34
+ console.print("\n[bold]Email template:[/bold]")
35
+ console.print(f"[dim]{template}[/dim]")
36
+ console.print()
@@ -0,0 +1,67 @@
1
+ import typer
2
+ from typing import Optional
3
+ from rich.console import Console
4
+ from inboxscan import __version__
5
+
6
+ app = typer.Typer(help="Find every subscription hiding in your email.")
7
+ console = Console()
8
+
9
+
10
+ @app.command()
11
+ def run(
12
+ email: list[str] = typer.Option(..., "--email", "-e", help="Email address to scan (repeat for multiple)"),
13
+ password: Optional[str] = typer.Option(None, "--password", "-p", help="Gmail app password (prompted if not provided)"),
14
+ ):
15
+ """Scan your email for active subscriptions."""
16
+ from inboxscan.models import EmailAccount, ScanResult
17
+ from inboxscan.connector import fetch_emails
18
+ from inboxscan.parser import parse_raw_email
19
+ from inboxscan.detector import detect_service
20
+ from inboxscan.reporter import print_report
21
+ from inboxscan.cache import save_result
22
+
23
+ all_subscriptions = []
24
+ seen_services: set[str] = set()
25
+
26
+ for email_addr in email:
27
+ pw = password or typer.prompt(f"Gmail app password for {email_addr}", hide_input=True)
28
+ account = EmailAccount(email=email_addr, password=pw)
29
+ console.print(f"\n[dim]Scanning {email_addr}...[/dim]")
30
+
31
+ for msg_id, raw in fetch_emails(account):
32
+ parsed = parse_raw_email(raw, msg_id, email_addr)
33
+ if parsed is None:
34
+ continue
35
+ sub = detect_service(parsed)
36
+ if sub is None:
37
+ continue
38
+ key = f"{sub.service_name}:{email_addr}"
39
+ if key in seen_services:
40
+ continue
41
+ seen_services.add(key)
42
+ sub.source_email = email_addr
43
+ all_subscriptions.append(sub)
44
+
45
+ result = ScanResult(
46
+ accounts_scanned=email,
47
+ subscriptions=all_subscriptions,
48
+ )
49
+ save_result(result)
50
+ print_report(result)
51
+
52
+
53
+ @app.command()
54
+ def cancel(service: str = typer.Argument(..., help="Service name (e.g. netflix, spotify)")):
55
+ """Print cancellation instructions for a service."""
56
+ from inboxscan.canceller import cancel_service
57
+ cancel_service(service)
58
+
59
+
60
+ @app.command()
61
+ def version():
62
+ """Print the current version."""
63
+ console.print(f"inboxscan {__version__}")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ app()
@@ -0,0 +1,35 @@
1
+ import imaplib
2
+ from typing import Iterator
3
+
4
+ from inboxscan.models import EmailAccount
5
+
6
+ SEARCH_SUBJECTS = [
7
+ "receipt", "invoice", "billing", "subscription",
8
+ "renewal", "payment confirmed", "charged",
9
+ ]
10
+
11
+
12
+ def build_search_query() -> str:
13
+ parts = [f'SUBJECT "{kw}"' for kw in SEARCH_SUBJECTS]
14
+ query = parts[0]
15
+ for part in parts[1:]:
16
+ query = f"OR ({query}) ({part})"
17
+ return query
18
+
19
+
20
+ def fetch_emails(account: EmailAccount) -> Iterator[tuple[str, bytes]]:
21
+ with imaplib.IMAP4_SSL(account.imap_host, account.imap_port) as imap:
22
+ imap.login(account.email, account.password)
23
+ imap.select("INBOX", readonly=True)
24
+ query = build_search_query()
25
+ _, message_numbers = imap.search(None, query)
26
+ if not message_numbers or not message_numbers[0]:
27
+ return
28
+ ids = message_numbers[0].split()
29
+ ids = ids[-500:]
30
+ for msg_id in ids:
31
+ _, data = imap.fetch(msg_id, "(RFC822)")
32
+ if data and data[0]:
33
+ raw = data[0][1]
34
+ if isinstance(raw, bytes):
35
+ yield msg_id.decode(), raw
@@ -0,0 +1,80 @@
1
+ import re
2
+ from datetime import date, timedelta
3
+ from typing import Optional
4
+
5
+ from inboxscan.models import ParsedEmail, Subscription, SubscriptionStatus
6
+
7
+ KNOWN_SERVICES: dict[str, tuple[str, str, str]] = {
8
+ "netflix.com": ("Netflix", "https://www.netflix.com/cancelplan", "monthly"),
9
+ "spotify.com": ("Spotify", "https://www.spotify.com/account/subscription/cancel", "monthly"),
10
+ "notion.so": ("Notion", "https://www.notion.so/profile/billing", "monthly"),
11
+ "adobe.com": ("Adobe CC", "https://account.adobe.com/plans", "monthly"),
12
+ "github.com": ("GitHub Pro", "https://github.com/settings/billing", "monthly"),
13
+ "figma.com": ("Figma", "https://www.figma.com/settings#billing", "monthly"),
14
+ "linear.app": ("Linear", "https://linear.app/settings/billing", "monthly"),
15
+ "slack.com": ("Slack", "https://slack.com/intl/en-us/help/articles/203875027", "monthly"),
16
+ "zoom.us": ("Zoom", "https://zoom.us/billing", "monthly"),
17
+ "dropbox.com": ("Dropbox", "https://www.dropbox.com/account/plan", "monthly"),
18
+ "atlassian.com": ("Atlassian", "https://admin.atlassian.com/", "monthly"),
19
+ "anthropic.com": ("Claude Pro", "https://claude.ai/settings", "monthly"),
20
+ "openai.com": ("ChatGPT Plus", "https://chat.openai.com/account/billing", "monthly"),
21
+ "amazon.com": ("Amazon Prime", "https://www.amazon.com/manageprime", "annual"),
22
+ "apple.com": ("Apple One", "https://appleid.apple.com/account/manage", "monthly"),
23
+ "google.com": ("Google One", "https://one.google.com/about", "monthly"),
24
+ "microsoft.com": ("Microsoft 365", "https://account.microsoft.com/services", "annual"),
25
+ "canva.com": ("Canva Pro", "https://www.canva.com/settings/billing", "monthly"),
26
+ "audible.com": ("Audible", "https://www.audible.com/account/optout", "monthly"),
27
+ "skillshare.com": ("Skillshare", "https://www.skillshare.com/settings/membership", "annual"),
28
+ "duolingo.com": ("Duolingo Plus", "https://www.duolingo.com/settings/subscription", "monthly"),
29
+ "grammarly.com": ("Grammarly", "https://account.grammarly.com/subscription", "monthly"),
30
+ "1password.com": ("1Password", "https://my.1password.com/", "annual"),
31
+ "dashlane.com": ("Dashlane", "https://app.dashlane.com/settings/subscription", "annual"),
32
+ "nordvpn.com": ("NordVPN", "https://my.nordaccount.com/dashboard/nordvpn/", "annual"),
33
+ "expressvpn.com": ("ExpressVPN", "https://www.expressvpn.com/subscriptions", "annual"),
34
+ "hulu.com": ("Hulu", "https://secure.hulu.com/account/cancel", "monthly"),
35
+ "disneyplus.com": ("Disney+", "https://www.disneyplus.com/account/subscription", "monthly"),
36
+ "youtube.com": ("YouTube Premium", "https://www.youtube.com/paid_memberships", "monthly"),
37
+ "cursor.sh": ("Cursor Pro", "https://cursor.sh/settings", "monthly"),
38
+ "vercel.com": ("Vercel Pro", "https://vercel.com/account/billing", "monthly"),
39
+ "render.com": ("Render", "https://dashboard.render.com/billing", "monthly"),
40
+ "railway.app": ("Railway", "https://railway.app/account/billing", "monthly"),
41
+ "supabase.com": ("Supabase", "https://supabase.com/dashboard/account/billing", "monthly"),
42
+ "datadog.com": ("Datadog", "https://app.datadoghq.com/billing/plan", "monthly"),
43
+ "sentry.io": ("Sentry", "https://sentry.io/settings/billing/", "monthly"),
44
+ "cloudflare.com": ("Cloudflare", "https://dash.cloudflare.com/profile/billing", "monthly"),
45
+ "twilio.com": ("Twilio", "https://console.twilio.com/billing", "monthly"),
46
+ "mailchimp.com": ("Mailchimp", "https://us1.admin.mailchimp.com/account/billing/", "monthly"),
47
+ }
48
+
49
+ DORMANT_THRESHOLD_DAYS = 90
50
+
51
+
52
+ def _extract_sender_domain(sender: str) -> str:
53
+ match = re.search(r"@([\w.]+)", sender)
54
+ return match.group(1).lower() if match else ""
55
+
56
+
57
+ def detect_service(parsed_email: ParsedEmail) -> Optional[Subscription]:
58
+ domain = _extract_sender_domain(parsed_email.sender)
59
+ for service_domain, (name, cancel_url, frequency) in KNOWN_SERVICES.items():
60
+ if domain.endswith(service_domain):
61
+ if parsed_email.amount is None:
62
+ return None
63
+ return Subscription(
64
+ service_name=name,
65
+ amount=parsed_email.amount,
66
+ currency=parsed_email.currency,
67
+ billing_frequency=frequency,
68
+ last_charge_date=parsed_email.date,
69
+ source_email="",
70
+ status=classify_status(parsed_email.date),
71
+ cancellation_url=cancel_url,
72
+ )
73
+ return None
74
+
75
+
76
+ def classify_status(last_charge_date: date) -> SubscriptionStatus:
77
+ days_since = (date.today() - last_charge_date).days
78
+ if days_since <= DORMANT_THRESHOLD_DAYS:
79
+ return SubscriptionStatus.ACTIVE
80
+ return SubscriptionStatus.DORMANT
@@ -0,0 +1,70 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import date
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+
7
+ class SubscriptionStatus(str, Enum):
8
+ ACTIVE = "ACTIVE"
9
+ DORMANT = "DORMANT"
10
+ UNKNOWN = "UNKNOWN"
11
+
12
+
13
+ @dataclass
14
+ class EmailAccount:
15
+ email: str
16
+ password: str # Gmail app password — never stored
17
+ imap_host: str = "imap.gmail.com"
18
+ imap_port: int = 993
19
+
20
+
21
+ @dataclass
22
+ class ParsedEmail:
23
+ message_id: str
24
+ sender: str
25
+ subject: str
26
+ date: date
27
+ body_text: str
28
+ amount: Optional[float] = None
29
+ currency: str = "USD"
30
+
31
+
32
+ @dataclass
33
+ class Subscription:
34
+ service_name: str
35
+ amount: float
36
+ currency: str
37
+ billing_frequency: str # "monthly", "annual", "unknown"
38
+ last_charge_date: date
39
+ source_email: str # which account found this
40
+ status: SubscriptionStatus = SubscriptionStatus.ACTIVE
41
+ cancellation_url: Optional[str] = None
42
+
43
+
44
+ @dataclass
45
+ class ScanResult:
46
+ accounts_scanned: list[str] = field(default_factory=list)
47
+ subscriptions: list[Subscription] = field(default_factory=list)
48
+ unknown_charges: int = 0
49
+
50
+ @property
51
+ def total_monthly_burn(self) -> float:
52
+ total = 0.0
53
+ for sub in self.subscriptions:
54
+ if sub.status == SubscriptionStatus.ACTIVE:
55
+ if sub.billing_frequency == "annual":
56
+ total += sub.amount / 12
57
+ else:
58
+ total += sub.amount
59
+ return round(total, 2)
60
+
61
+ @property
62
+ def dormant_monthly_waste(self) -> float:
63
+ total = 0.0
64
+ for sub in self.subscriptions:
65
+ if sub.status == SubscriptionStatus.DORMANT:
66
+ if sub.billing_frequency == "annual":
67
+ total += sub.amount / 12
68
+ else:
69
+ total += sub.amount
70
+ return round(total, 2)
@@ -0,0 +1,80 @@
1
+ import email
2
+ import re
3
+ from datetime import date, datetime
4
+ from email.message import Message
5
+ from typing import Optional
6
+
7
+ from inboxscan.models import ParsedEmail
8
+
9
+ SUBSCRIPTION_KEYWORDS = [
10
+ "receipt", "invoice", "billing", "payment confirmed",
11
+ "subscription", "renewal", "charged", "your order",
12
+ "monthly plan", "annual plan", "membership",
13
+ ]
14
+
15
+ AMOUNT_PATTERNS = [
16
+ r"\$\s*(\d+\.\d{2})",
17
+ r"USD\s*(\d+\.\d{2})",
18
+ r"(\d+\.\d{2})\s*USD",
19
+ r"amount[:\s]+\$?(\d+\.\d{2})",
20
+ r"charged[:\s]+\$?(\d+\.\d{2})",
21
+ r"total[:\s]+\$?(\d+\.\d{2})",
22
+ ]
23
+
24
+
25
+ def is_subscription_email(subject: str) -> bool:
26
+ subject_lower = subject.lower()
27
+ return any(kw in subject_lower for kw in SUBSCRIPTION_KEYWORDS)
28
+
29
+
30
+ def parse_amount(text: str) -> Optional[float]:
31
+ for pattern in AMOUNT_PATTERNS:
32
+ match = re.search(pattern, text, re.IGNORECASE)
33
+ if match:
34
+ try:
35
+ return float(match.group(1))
36
+ except ValueError:
37
+ continue
38
+ return None
39
+
40
+
41
+ def extract_body_text(msg: Message) -> str:
42
+ body = ""
43
+ if msg.is_multipart():
44
+ for part in msg.walk():
45
+ if part.get_content_type() == "text/plain":
46
+ payload = part.get_payload(decode=True)
47
+ if payload:
48
+ body += payload.decode("utf-8", errors="ignore")
49
+ else:
50
+ payload = msg.get_payload(decode=True)
51
+ if payload:
52
+ body = payload.decode("utf-8", errors="ignore")
53
+ return body
54
+
55
+
56
+ def parse_raw_email(raw: bytes, message_id: str, source_email: str) -> Optional[ParsedEmail]:
57
+ msg = email.message_from_bytes(raw)
58
+ subject = msg.get("Subject", "")
59
+ sender = msg.get("From", "")
60
+ date_str = msg.get("Date", "")
61
+
62
+ if not is_subscription_email(subject):
63
+ return None
64
+
65
+ try:
66
+ parsed_date = datetime.strptime(date_str[:16].strip(), "%a, %d %b %Y").date()
67
+ except (ValueError, TypeError):
68
+ parsed_date = date.today()
69
+
70
+ body = extract_body_text(msg)
71
+ amount = parse_amount(body) or parse_amount(subject)
72
+
73
+ return ParsedEmail(
74
+ message_id=message_id,
75
+ sender=sender,
76
+ subject=subject,
77
+ date=parsed_date,
78
+ body_text=body,
79
+ amount=amount,
80
+ )
@@ -0,0 +1,52 @@
1
+ from rich.console import Console
2
+ from rich.table import Table
3
+ from rich import box
4
+ from inboxscan.models import ScanResult, SubscriptionStatus
5
+
6
+ console = Console()
7
+
8
+
9
+ def print_report(result: ScanResult) -> None:
10
+ console.print("\n[bold]INBOXSCAN REPORT[/bold]")
11
+ console.print("═" * 60)
12
+ console.print(f"Scanned: {', '.join(result.accounts_scanned)}")
13
+ console.print(
14
+ f"Found: [bold]{len(result.subscriptions)}[/bold] subscriptions "
15
+ f"| Total burn: [bold green]${result.total_monthly_burn:.2f}/mo[/bold green]"
16
+ )
17
+ console.print()
18
+
19
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))
20
+ table.add_column("Status", style="bold", width=10)
21
+ table.add_column("Service", width=20)
22
+ table.add_column("Amount", width=12)
23
+ table.add_column("Last charge", width=12)
24
+ table.add_column("Account", style="dim")
25
+
26
+ for sub in sorted(result.subscriptions, key=lambda s: s.status.value):
27
+ status_str = {
28
+ SubscriptionStatus.ACTIVE: "[green]\\[ACTIVE][/green]",
29
+ SubscriptionStatus.DORMANT: "[yellow]\\[DORMANT][/yellow]",
30
+ SubscriptionStatus.UNKNOWN: "[dim]\\[UNKNOWN][/dim]",
31
+ }[sub.status]
32
+ table.add_row(
33
+ status_str,
34
+ sub.service_name,
35
+ f"${sub.amount:.2f}/{sub.billing_frequency[:2]}",
36
+ sub.last_charge_date.strftime("%b %d"),
37
+ sub.source_email,
38
+ )
39
+
40
+ console.print(table)
41
+ console.print("═" * 60)
42
+
43
+ if result.dormant_monthly_waste > 0:
44
+ console.print(
45
+ f"[yellow]Potential savings: ${result.dormant_monthly_waste:.2f}/mo "
46
+ f"(cancel DORMANT subscriptions)[/yellow]"
47
+ )
48
+
49
+ if result.unknown_charges > 0:
50
+ console.print(f"[dim]{result.unknown_charges} unrecognized recurring charges — review manually[/dim]")
51
+
52
+ console.print("\nRun [bold]inboxscan cancel <service>[/bold] for cancellation steps.\n")
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: inboxscan
3
+ Version: 0.1.0
4
+ Summary: Find every subscription hiding in your email — zero credentials stored, nothing leaves your machine.
5
+ Author: Sravya Vedantham
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ License-File: LICENSE
9
+ Requires-Dist: typer>=0.9.0
10
+ Requires-Dist: rich>=13.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
13
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
14
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
15
+ Requires-Dist: black>=23.0.0; extra == "dev"
16
+ Requires-Dist: isort>=5.0.0; extra == "dev"
17
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
18
+ Dynamic: license-file
@@ -0,0 +1,23 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/inboxscan/__init__.py
5
+ src/inboxscan/cache.py
6
+ src/inboxscan/canceller.py
7
+ src/inboxscan/cli.py
8
+ src/inboxscan/connector.py
9
+ src/inboxscan/detector.py
10
+ src/inboxscan/models.py
11
+ src/inboxscan/parser.py
12
+ src/inboxscan/reporter.py
13
+ src/inboxscan.egg-info/PKG-INFO
14
+ src/inboxscan.egg-info/SOURCES.txt
15
+ src/inboxscan.egg-info/dependency_links.txt
16
+ src/inboxscan.egg-info/entry_points.txt
17
+ src/inboxscan.egg-info/requires.txt
18
+ src/inboxscan.egg-info/top_level.txt
19
+ tests/test_cache.py
20
+ tests/test_connector.py
21
+ tests/test_detector.py
22
+ tests/test_models.py
23
+ tests/test_parser.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inboxscan = inboxscan.cli:app
@@ -0,0 +1,10 @@
1
+ typer>=0.9.0
2
+ rich>=13.0.0
3
+
4
+ [dev]
5
+ pytest>=7.0.0
6
+ pytest-cov>=4.0.0
7
+ mypy>=1.0.0
8
+ black>=23.0.0
9
+ isort>=5.0.0
10
+ flake8>=6.0.0
@@ -0,0 +1 @@
1
+ inboxscan
@@ -0,0 +1,21 @@
1
+ from datetime import date
2
+ from pathlib import Path
3
+ from inboxscan.cache import save_result, load_result
4
+ from inboxscan.models import ScanResult, Subscription, SubscriptionStatus
5
+
6
+
7
+ def test_save_and_load_roundtrip(tmp_path):
8
+ db_path = tmp_path / "cache.db"
9
+ sub = Subscription("Netflix", 15.99, "USD", "monthly", date(2026, 3, 1), "test@gmail.com", SubscriptionStatus.ACTIVE)
10
+ result = ScanResult(accounts_scanned=["test@gmail.com"], subscriptions=[sub])
11
+ save_result(result, path=db_path)
12
+ loaded = load_result(path=db_path)
13
+ assert loaded is not None
14
+ assert len(loaded.subscriptions) == 1
15
+ assert loaded.subscriptions[0].service_name == "Netflix"
16
+ assert loaded.subscriptions[0].amount == 15.99
17
+
18
+
19
+ def test_load_result_missing_file(tmp_path):
20
+ result = load_result(path=tmp_path / "nonexistent.db")
21
+ assert result is None
@@ -0,0 +1,12 @@
1
+ from inboxscan.connector import build_search_query
2
+
3
+
4
+ def test_build_search_query_returns_string():
5
+ query = build_search_query()
6
+ assert isinstance(query, str)
7
+ assert len(query) > 0
8
+
9
+
10
+ def test_build_search_query_contains_receipt():
11
+ query = build_search_query()
12
+ assert "receipt" in query.lower()
@@ -0,0 +1,37 @@
1
+ from datetime import date, timedelta
2
+ from inboxscan.detector import detect_service, classify_status, KNOWN_SERVICES
3
+ from inboxscan.models import ParsedEmail, SubscriptionStatus
4
+
5
+
6
+ def test_detect_netflix_by_sender():
7
+ e = ParsedEmail("id1", "billing@netflix.com", "Your Netflix receipt", date.today(), "", 15.99)
8
+ result = detect_service(e)
9
+ assert result is not None
10
+ assert result.service_name == "Netflix"
11
+
12
+
13
+ def test_detect_notion_by_sender():
14
+ e = ParsedEmail("id2", "billing@notion.so", "Notion receipt", date.today(), "", 16.00)
15
+ result = detect_service(e)
16
+ assert result is not None
17
+ assert result.service_name == "Notion"
18
+
19
+
20
+ def test_unknown_service_returns_none():
21
+ e = ParsedEmail("id3", "billing@unknownxyz123.com", "Your receipt", date.today(), "", 9.99)
22
+ result = detect_service(e)
23
+ assert result is None
24
+
25
+
26
+ def test_classify_active_recent():
27
+ status = classify_status(date.today() - timedelta(days=20))
28
+ assert status == SubscriptionStatus.ACTIVE
29
+
30
+
31
+ def test_classify_dormant_old():
32
+ status = classify_status(date.today() - timedelta(days=120))
33
+ assert status == SubscriptionStatus.DORMANT
34
+
35
+
36
+ def test_known_services_has_minimum_entries():
37
+ assert len(KNOWN_SERVICES) >= 20
@@ -0,0 +1,35 @@
1
+ from datetime import date
2
+ from inboxscan.models import ScanResult, Subscription, SubscriptionStatus
3
+
4
+
5
+ def test_total_monthly_burn_active_only():
6
+ result = ScanResult(
7
+ accounts_scanned=["test@gmail.com"],
8
+ subscriptions=[
9
+ Subscription("Netflix", 15.99, "USD", "monthly", date.today(), "test@gmail.com", SubscriptionStatus.ACTIVE),
10
+ Subscription("Adobe", 54.99, "USD", "monthly", date.today(), "test@gmail.com", SubscriptionStatus.ACTIVE),
11
+ ],
12
+ )
13
+ assert result.total_monthly_burn == 70.98
14
+
15
+
16
+ def test_annual_subscription_divided_by_12():
17
+ result = ScanResult(
18
+ accounts_scanned=["test@gmail.com"],
19
+ subscriptions=[
20
+ Subscription("GitHub", 48.00, "USD", "annual", date.today(), "test@gmail.com", SubscriptionStatus.ACTIVE),
21
+ ],
22
+ )
23
+ assert result.total_monthly_burn == 4.0
24
+
25
+
26
+ def test_dormant_not_counted_in_active_burn():
27
+ result = ScanResult(
28
+ accounts_scanned=["test@gmail.com"],
29
+ subscriptions=[
30
+ Subscription("Netflix", 15.99, "USD", "monthly", date.today(), "test@gmail.com", SubscriptionStatus.ACTIVE),
31
+ Subscription("Audible", 14.95, "USD", "monthly", date.today(), "test@gmail.com", SubscriptionStatus.DORMANT),
32
+ ],
33
+ )
34
+ assert result.total_monthly_burn == 15.99
35
+ assert result.dormant_monthly_waste == 14.95
@@ -0,0 +1,26 @@
1
+ from datetime import date
2
+ from inboxscan.parser import parse_amount, is_subscription_email
3
+
4
+
5
+ def test_parse_amount_dollar_sign():
6
+ assert parse_amount("Your charge of $15.99 has been processed") == 15.99
7
+
8
+
9
+ def test_parse_amount_usd_format():
10
+ assert parse_amount("Amount: USD 54.99") == 54.99
11
+
12
+
13
+ def test_parse_amount_no_amount():
14
+ assert parse_amount("Welcome to our newsletter") is None
15
+
16
+
17
+ def test_is_subscription_email_receipt():
18
+ assert is_subscription_email("Your Netflix receipt for March") is True
19
+
20
+
21
+ def test_is_subscription_email_renewal():
22
+ assert is_subscription_email("Your subscription has been renewed") is True
23
+
24
+
25
+ def test_is_subscription_email_negative():
26
+ assert is_subscription_email("Your friend sent you a message") is False