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.
- inboxscan-0.1.0/LICENSE +21 -0
- inboxscan-0.1.0/PKG-INFO +18 -0
- inboxscan-0.1.0/README.md +75 -0
- inboxscan-0.1.0/pyproject.toml +42 -0
- inboxscan-0.1.0/setup.cfg +4 -0
- inboxscan-0.1.0/src/inboxscan/__init__.py +1 -0
- inboxscan-0.1.0/src/inboxscan/cache.py +62 -0
- inboxscan-0.1.0/src/inboxscan/canceller.py +36 -0
- inboxscan-0.1.0/src/inboxscan/cli.py +67 -0
- inboxscan-0.1.0/src/inboxscan/connector.py +35 -0
- inboxscan-0.1.0/src/inboxscan/detector.py +80 -0
- inboxscan-0.1.0/src/inboxscan/models.py +70 -0
- inboxscan-0.1.0/src/inboxscan/parser.py +80 -0
- inboxscan-0.1.0/src/inboxscan/reporter.py +52 -0
- inboxscan-0.1.0/src/inboxscan.egg-info/PKG-INFO +18 -0
- inboxscan-0.1.0/src/inboxscan.egg-info/SOURCES.txt +23 -0
- inboxscan-0.1.0/src/inboxscan.egg-info/dependency_links.txt +1 -0
- inboxscan-0.1.0/src/inboxscan.egg-info/entry_points.txt +2 -0
- inboxscan-0.1.0/src/inboxscan.egg-info/requires.txt +10 -0
- inboxscan-0.1.0/src/inboxscan.egg-info/top_level.txt +1 -0
- inboxscan-0.1.0/tests/test_cache.py +21 -0
- inboxscan-0.1.0/tests/test_connector.py +12 -0
- inboxscan-0.1.0/tests/test_detector.py +37 -0
- inboxscan-0.1.0/tests/test_models.py +35 -0
- inboxscan-0.1.0/tests/test_parser.py +26 -0
inboxscan-0.1.0/LICENSE
ADDED
|
@@ -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.
|
inboxscan-0.1.0/PKG-INFO
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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
|