From d523ddb8bcac7e5e5941dfe912f0133180c0c259 Mon Sep 17 00:00:00 2001 From: Gunnar Date: Mon, 12 Jan 2026 12:45:08 -0600 Subject: [PATCH 1/2] Many changes --- .python-version | 1 + AutoUnsubscriber.py | 530 +++++++++++++++++++++++++++----------------- README.md | 0 README.txt | 38 +++- main.py | 6 + pyproject.toml | 12 + requirements.txt | 4 + unsubscriber.log | 13 ++ uv.lock | 92 ++++++++ 9 files changed, 484 insertions(+), 212 deletions(-) create mode 100644 .python-version create mode 100644 README.md create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 unsubscriber.log create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/AutoUnsubscriber.py b/AutoUnsubscriber.py index 8ddc46b..18baa72 100644 --- a/AutoUnsubscriber.py +++ b/AutoUnsubscriber.py @@ -1,30 +1,45 @@ #! python3 -import pyzmail -import imapclient -import bs4 import getpass -import webbrowser +import logging import re import sys +import webbrowser -'''List of accepted service providers and respective imap link''' -servers = [('Gmail','imap.gmail.com'),('Outlook','imap-mail.outlook.com'), - ('Hotmail','imap-mail.outlook.com'),('Yahoo','imap.mail.yahoo.com'), - ('ATT','imap.mail.att.net'),('Comcast','imap.comcast.net'), - ('Verizon','incoming.verizon.net'),('AOL','imap.aol.com'), - ('Zoho','imap.zoho.com')] +import bs4 +import imapclient +import pyzmail +from tqdm import tqdm + +# --- Configuration & Logging Setup --- +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("unsubscriber.log"), logging.StreamHandler()], +) -#add to words if more words found -'''Key words for unsubscribe link - add more if found''' -words = ['unsubscribe','subscription','optout'] +"""List of common service providers and respective imap links""" +servers = [ + ("Gmail", "imap.gmail.com"), + ("Outlook", "imap-mail.outlook.com"), + ("Hotmail", "imap-mail.outlook.com"), + ("Yahoo", "imap.mail.yahoo.com"), + ("ATT", "imap.mail.att.net"), + ("Comcast", "imap.comcast.net"), + ("Verizon", "incoming.verizon.net"), + ("AOL", "imap.aol.com"), + ("Zoho", "imap.zoho.com"), +] -class AutoUnsubscriber(): +"""Key words for unsubscribe link""" +words = ["unsubscribe", "subscription", "optout"] + +class AutoUnsubscriber: def __init__(self): - self.email = '' + self.email = "" self.user = None - self.password = '' + self.password = "" self.imap = None self.goToLinks = False self.delEmails = False @@ -32,270 +47,369 @@ def __init__(self): self.noLinkList = [] self.wordCheck = [] self.providers = [] + + # Regex compilation for i in range(len(servers)): self.providers.append(re.compile(servers[i][0], re.I)) + for i in range(len(words)): self.wordCheck.append(re.compile(words[i], re.I)) - '''Get initial user info - email, password, and service provider''' + """Get initial user info""" + def getInfo(self): - print('This program searchs your email for junk mail to unsubscribe from and delete') - print('Suported emails: Gmail, Outlook, Hotmail, Yahoo, AOL, Zoho,') - print('AT&T, Comcast, and Verizon') - print('Please note: you may need to allow access to less secure apps') + logging.info("Starting AutoUnsubscriber...") + print( + "Auto-detected providers: Gmail, Outlook, Hotmail, Yahoo, AOL, Zoho, AT&T, Comcast, Verizon" + ) + getEmail = True while getEmail: - self.email = input('\nEnter your email address: ') + self.email = input("\nEnter your email address: ") + found_provider = False + + # Check against known providers for j in range(len(self.providers)): - choice = self.providers[j].search(self.email) - if choice != None: + if self.providers[j].search(self.email): self.user = servers[j] - print('\nLooks like you\'re using a '+self.user[0]+' account\n') + logging.info(f"Detected provider: {self.user[0]}") + found_provider = True getEmail = False break - if self.user == None: - print('\nNo useable email type detected, try a different account') - self.password = getpass.getpass('Enter password for '+self.email+': ') - '''Log in to IMAP server, argument determines whether readonly or not''' + # Manual Override if not found + if not found_provider: + print("\nProvider not auto-detected.") + manual = input( + "Enter your IMAP server manually (e.g., imap.fastmail.com) or press Enter to retry email: " + ) + if manual.strip(): + # Format: (CustomName, IMAP Address) + self.user = ("Custom", manual.strip()) + logging.info(f"Using manual provider: {self.user[1]}") + getEmail = False + + self.password = getpass.getpass(f"Enter password for {self.email}: ") + + """Log in to IMAP server""" + def login(self, read=True): - try: + try: + logging.info(f"Connecting to {self.user[1]}...") self.imap = imapclient.IMAPClient(self.user[1], ssl=True) self.imap._MAXLINE = 10000000 self.imap.login(self.email, self.password) - self.imap.select_folder('INBOX', readonly=read) - print('\nLog in successful\n') + self.imap.select_folder("INBOX", readonly=read) + logging.info(f"Login successful. Read-only mode: {read}") return True - except: - print('\nAn error occured while attempting to log in, please try again\n') + except Exception as e: + logging.error(f"Login failed: {e}") return False - '''Attempt to log in to server. On failure, force user to re-enter info''' + """Attempt to log in to server""" + def accessServer(self, readonly=True): - if self.email == '': + if self.email == "": self.getInfo() attempt = self.login(readonly) if attempt == False: + print("Login failed. Let's try again.") self.newEmail() self.accessServer(readonly) - '''Search for emails with unsubscribe in the body. If sender not already in - senderList, parse email for unsubscribe link. If link found, add name, email, - link (plus metadata for decisions) to senderList. If not, add to noLinkList. - ''' + """Search for emails and parse for links""" + def getEmails(self): - print('Getting emails with unsubscribe in the body\n') - UIDs = self.imap.search(['BODY unsubscribe']) - raw = self.imap.fetch(UIDs, ['BODY[]']) - print('Getting links and addresses\n') - for UID in UIDs: - '''Get address and check if sender already in senderList''' - msg = pyzmail.PyzMessage.factory(raw[UID][b'BODY[]']) - sender = msg.get_addresses('from') - trySender = True - for spammers in self.senderList: - if sender[0][1] in spammers: - trySender = False - '''If not, search for link''' - if trySender: - '''Encode and decode to cp437 to handle unicode errors and get - rid of characters that can't be printed by Windows command line - which has default setting of cp437 - ''' - senderName = (sender[0][0].encode('cp437', 'ignore')) - senderName = senderName.decode('cp437') - print('Searching for unsubscribe link from '+str(senderName)) - url = False - '''Parse html for elements with anchor tags''' - if msg.html_part != None: - html = msg.html_part.get_payload().decode('utf-8') - soup = bs4.BeautifulSoup(html, 'html.parser') - elems = soup.select('a') - '''For each anchor tag, use regex to search for key words''' - for i in range(len(elems)): - for j in range(len(self.wordCheck)): - k = self.wordCheck[j].search(str(elems[i])) - '''If one is found, get the url''' - if k != None: - print('Link found') - url = elems[i].get('href') - break - if url != False: - break - '''If link found, add info to senderList - format: (Name, email, link, go to link, delete emails) - If no link found, add to noLinkList - ''' - if url != False: - self.senderList.append([senderName, sender[0][1], url, False, False]) - else: - print('No link found') - notInList = True - for noLinkers in self.noLinkList: - if sender[0][1] in noLinkers: - notInList = False - if notInList: - self.noLinkList.append([sender[0][0], sender[0][1]]) - print('\nLogging out of email server\n') - self.imap.logout() + logging.info("Searching INBOX for 'unsubscribe' keyword...") + + try: + # FIX 1: Split search terms for stricter IMAP servers (Zoho, etc.) + UIDs = self.imap.search(["BODY", "unsubscribe"]) + total_emails = len(UIDs) + logging.info( + f"Found {total_emails} emails containing 'unsubscribe'. Fetching data in batches..." + ) + + # FIX 2: Process in batches to avoid server timeouts/socket errors + batch_size = 50 + + # Initialize progress bar for the total count + pbar = tqdm(total=total_emails, desc="Scanning Emails", unit="email") + + # Loop through UIDs in chunks + for i in range(0, total_emails, batch_size): + batch_UIDs = UIDs[i : i + batch_size] + + try: + raw = self.imap.fetch(batch_UIDs, ["BODY[]"]) + + for UID in batch_UIDs: + if UID not in raw: + continue + + msg = pyzmail.PyzMessage.factory(raw[UID][b"BODY[]"]) + sender = msg.get_addresses("from") + + if not sender: + pbar.update(1) + continue + + # Check duplication + trySender = True + for spammers in self.senderList: + if sender[0][1] in spammers: + trySender = False + + if trySender: + try: + senderName = ( + sender[0][0].encode("cp437", "ignore") + ).decode("cp437") + except: + senderName = "Unknown Sender" + + url = False + if msg.html_part != None: + try: + html = msg.html_part.get_payload().decode( + "utf-8", errors="ignore" + ) + soup = bs4.BeautifulSoup(html, "html.parser") + elems = soup.select("a") + + for k in range(len(elems)): + for j in range(len(self.wordCheck)): + if self.wordCheck[j].search(str(elems[k])): + url = elems[k].get("href") + break + if url: + break + except Exception: + pass + + if url: + self.senderList.append( + [senderName, sender[0][1], url, False, False] + ) + else: + notInList = True + for noLinkers in self.noLinkList: + if sender[0][1] in noLinkers: + notInList = False + if notInList: + self.noLinkList.append([sender[0][0], sender[0][1]]) + + pbar.update(1) + + except Exception as batch_err: + logging.error( + f"Error processing batch starting at index {i}: {batch_err}" + ) + continue + + pbar.close() + logging.info( + f"Scan complete. Found {len(self.senderList)} unique senders with links." + ) + self.imap.logout() + + except Exception as e: + logging.error(f"Critical error during email fetching: {e}") + + """Display info""" - '''Display info about which providers links were/were not found for''' def displayEmailInfo(self): - if self.noLinkList != []: - print('Could not find unsubscribe links from these senders:') - noList = '| ' - for i in range(len(self.noLinkList)): - noList += (str(self.noLinkList[i][0])+' | ') - print(noList) - if self.senderList != []: - print('\nFound unsubscribe links from these senders:') - fullList = '| ' - for i in range(len(self.senderList)): - fullList += (str(self.senderList[i][0])+' | ') - print(fullList) - - '''Allow user to decide which unsubscribe links to follow/emails to delete''' + print("\n" + "=" * 40) + print(" SCAN RESULTS ") + print("=" * 40) + + if self.noLinkList: + print( + f"\n[!] Senders found (but NO unsubscribe link detected): {len(self.noLinkList)}" + ) + + if self.senderList: + print(f"\n[+] Senders found WITH unsubscribe links: {len(self.senderList)}") + for i, sender in enumerate(self.senderList): + print(f" {i + 1}. {sender[0]} ({sender[1]})") + + """User Decisions""" + def decisions(self): def choice(userInput): - if userInput.lower() == 'y': return True - elif userInput.lower() == 'n': return False - else: return None + if userInput.lower() == "y": + return True + elif userInput.lower() == "n": + return False + else: + return None + self.displayEmailInfo() - print('\nYou may now decide which emails to unsubscribe from and/or delete') - print('Navigating to unsubscribe links may not automatically unsubscribe you') - print('Please note: deleted emails cannot be recovered\n') - for j in range(len(self.senderList)): - while True: - unsub = input('Open unsubscribe link from '+str(self.senderList[j][0])+' (Y/N): ') - c = choice(unsub) - if c: - self.senderList[j][3] = True + + if not self.senderList: + return + + print("\n--- Decision Time ---") + print("Review the list above. You can choose to open links or delete emails.") + + mode = input( + "\nType 'all' to process all senders, or 'each' to decide one by one: " + ).lower() + + if mode == "all": + open_all = choice(input("Open ALL unsubscribe links? (Y/N): ")) + del_all = choice(input("Delete ALL emails from these senders? (Y/N): ")) + + for item in self.senderList: + if open_all: + item[3] = True self.goToLinks = True - break - elif not c: - break - else: - print('Invalid choice, please enter \'Y\' or \'N\'.\n') - while True: - delete = input('Delete emails from '+str(self.senderList[j][1])+' (Y/N): ') - d = choice(delete) - if d: - self.senderList[j][4] = True + if del_all: + item[4] = True self.delEmails = True - break - elif not d: - break - else: - print('Invalid choice, please enter \'Y\' or \'N\'.\n') - '''Navigate to selected unsubscribe, 10 at a time''' - def openLinks(self): - if self.goToLinks != True: - print('\nNo unsubscribe links selected to naviagte to') else: - print('\nUnsubscribe links will be opened 10 at a time') - counter = 0 - for i in range(len(self.senderList)): - if self.senderList[i][3] == True: - webbrowser.open(self.senderList[i][2]) - counter += 1 - if counter == 10: - print('Navigating to unsubscribe links') - cont = input('Press \'Enter\' to continue: ') - counter = 0 - - '''Log back into IMAP servers, NOT in readonly mode, and delete emails from - selected providers. Note: only deleting emails with unsubscribe in the body. - Emails from provider without unsubscribe in the body will not be deleted. - ''' + for j in range(len(self.senderList)): + print(f"\nSender: {self.senderList[j][0]}") + while True: + unsub = input(" Open unsubscribe link? (Y/N): ") + c = choice(unsub) + if c is not None: + if c: + self.senderList[j][3] = True + self.goToLinks = True + break + + while True: + delete = input(" Delete emails from this sender? (Y/N): ") + d = choice(delete) + if d is not None: + if d: + self.senderList[j][4] = True + self.delEmails = True + break + + """Open Links""" + + def openLinks(self): + if not self.goToLinks: + return + + logging.info("Opening unsubscribe links...") + links_to_open = [s[2] for s in self.senderList if s[3]] + + batch_size = 10 + for i in range(0, len(links_to_open), batch_size): + batch = links_to_open[i : i + batch_size] + print( + f"\nOpening batch {i // batch_size + 1} of {(len(links_to_open) // batch_size) + 1}..." + ) + + for link in batch: + webbrowser.open(link) + + if i + batch_size < len(links_to_open): + input("Paused. Press 'Enter' to open the next batch of links...") + + """Delete Emails (Optimized)""" + def deleteEmails(self): - if self.delEmails != True: - print('\nNo emails selected to delete') + if not self.delEmails: + return + + targets = [s[1] for s in self.senderList if s[4]] + print( + f"\n[WARNING] You have selected to delete emails from {len(targets)} senders." + ) + print("These cannot be recovered.") + confirm = input("Type 'DELETE' to confirm: ") + + if confirm != "DELETE": + logging.info("Deletion cancelled by user.") + return + + logging.info("Logging in for deletion (Write Mode)...") + if not self.login(False): + return + + total_marked = 0 + + for sender_data in tqdm(self.senderList, desc="Processing Deletions"): + if sender_data[4] == True: + email_addr = sender_data[1] + + # Split search terms for server compatibility + DelUIDs = self.imap.search(["BODY", "unsubscribe", "FROM", email_addr]) + + if DelUIDs: + self.imap.delete_messages(DelUIDs) + total_marked += len(DelUIDs) + + if total_marked > 0: + logging.info(f"Expunging {total_marked} messages from server...") + self.imap.expunge() + logging.info("Expunge complete.") else: - print('\nLogging into email server to delete emails') - '''Pass false to self.login() so as to NOT be in readonly mode''' - self.login(False) - DelTotal = 0 - for i in range(len(self.senderList)): - if self.senderList[i][4] == True: - print('Searching for emails to delete from '+str(self.senderList[i][1])) - fromSender = 'FROM '+str(self.senderList[i][1]) - '''Search for unsubscribe in body from selected providers''' - DelUIDs = self.imap.search(['BODY unsubscribe', fromSender]) - DelCount = 0 - for DelUID in DelUIDs: - '''Delete emails from selected providers''' - self.imap.delete_messages(DelUID) - self.imap.expunge() - DelCount += 1 - print('Deleted '+str(DelCount)+' emails from '+str(self.senderList[i][1])) - DelTotal += DelCount - print('\nTotal emails deleted: '+str(DelTotal)) - print('\nLogging out of email server') - self.imap.logout() + logging.info("No messages found to delete.") + + self.imap.logout() - '''For re-running on same email. Clear lists, reset flags, but use same info - for email, password, email provider, etc. - ''' def runAgain(self): self.goToLinks = False self.delEmails = False self.senderList = [] self.noLinkList = [] - '''Reset everything to get completely new user info''' def newEmail(self): - self.email = '' + self.email = "" self.user = None - self.password = '' + self.password = "" self.imap = None self.runAgain() - '''Called after program has run, allow user to run again on same email, run - on a different email, or quit the program - ''' def nextMove(self): - print('\nRun this program again on the same email, a different email, or quit?\n') + print("\n" + "-" * 30) while True: - print('Press \'A\' to run again on '+str(self.email)) - print('Press \'D\' to run on a different email address') - again = input('Press \'Q\' to quit: ') - if again.lower() == 'a': - print('\nRunning program again for '+str(self.email)+'\n') + print(f"Current Email: {self.email}") + print(" [A] Run again on same email") + print(" [D] Different email") + print(" [Q] Quit") + choice = input("Choice: ").lower() + + if choice == "a": self.runAgain() return True - elif again.lower() == 'd': - print('\nPreparing program to run on a different email address\n') + elif choice == "d": self.newEmail() return False - elif again.lower() == 'q': - print('\nSo long, space cowboy!\n') + elif choice == "q": + logging.info("Exiting program.") sys.exit() - else: - print('\nInvalid choice, please enter \'A\', \'D\' or \'Q\'.\n') - '''Full set of program commands. Works whether it has user info or not''' def fullProcess(self): self.accessServer() self.getEmails() - if self.senderList != []: + if self.senderList: self.decisions() self.openLinks() self.deleteEmails() else: - print('No unsubscribe links detected') + logging.info("No unsubscribe links detected in search.") - '''Loop to run program and not quit until told to by user or closed''' def usageLoop(self): self.fullProcess() while True: self.nextMove() self.fullProcess() - + def main(): - Auto = AutoUnsubscriber() - Auto.usageLoop() + try: + Auto = AutoUnsubscriber() + Auto.usageLoop() + except KeyboardInterrupt: + print("\nProgram interrupted by user.") + sys.exit() -if __name__ == '__main__': - main() +if __name__ == "__main__": + main() diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/README.txt b/README.txt index 5ecc878..88cdf67 100644 --- a/README.txt +++ b/README.txt @@ -1,7 +1,37 @@ -This program is an email auto-unsubscriber. Depending on your email provider and settings, it may require you to allow access to less secure apps. +# Auto Unsubscriber -It uses IMAP to log into your email. From there, it goes through every email with "unsubscribe" in the body, parses the HTML, and uses regex to search through anchor tags for keywords that indicate an unsubscribe link (unsubscribe, optout, etc). If it finds a match, it grabs the href link and puts the address and link in a list. +A Python utility that scans your email for newsletters and spam, finds unsubscribe links, and helps you bulk-unsubscribe and delete old emails. -After the program has a list of emails and links, for each address in the list, it gives the user the option to navigate to the unsubscribe link and to delete emails with unsubscribe in the body from the sender. -Once the program finishes going through the list, it gives the user the option to run the program again on the same email address, run it on a different email address, or quit the program. \ No newline at end of file + +## Features + +* **Universal IMAP Support:** Auto-detects major providers (Gmail, Yahoo, Outlook, Zoho, etc.) and supports **manual entry** for custom domains or private servers. +* **Smart Parsing:** Scans email bodies for unsubscribe links using `BeautifulSoup`. +* **Safety First:** + * Runs in "Read Only" mode during the scanning phase. + * Requires explicit `DELETE` confirmation before removing any data. + * Opens links in small batches to prevent browser crashes. +* **High Performance:** + * Uses **Batch Fetching** (50 emails at a time) to prevent server timeouts. + * Uses **Batch Deletion** and single-pass `EXPUNGE` for maximum speed. +* **Interactive:** Review senders one by one or process them all in bulk. + +## Prerequisites + +* **Python 3.8+** +* **uv** (Recommended for dependency management) or `pip`. +* **App Password:** If you use 2FA (Gmail, Zoho, Yahoo, etc.), you must use an App-Specific Password, not your regular login password. + +## Usage with `uv` (Recommended) + +This project uses [`uv`](https://github.com/astral-sh/uv) for fast, isolated execution without needing to create manual virtual environments. + +### 1. Clone or Download +Clone the repo + +### 2. Install requirements +uv add -r requirements.txt + +### 3. Run +uv run AutoUnsubscriber.py \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..1f177dd --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from autounsubscriber!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8d33809 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "autounsubscriber" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "beautifulsoup4>=4.14.3", + "imapclient>=3.0.1", + "pyzmail36>=1.0.5", + "tqdm>=4.67.1", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e123d3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pyzmail36 +imapclient +beautifulsoup4 +tqdm \ No newline at end of file diff --git a/unsubscriber.log b/unsubscriber.log new file mode 100644 index 0000000..3ce8a7d --- /dev/null +++ b/unsubscriber.log @@ -0,0 +1,13 @@ +2026-01-12 12:43:00,098 - INFO - Starting AutoUnsubscriber... +2026-01-12 12:43:10,250 - INFO - Using manual provider: imappro.zoho.com +2026-01-12 12:43:13,352 - INFO - Connecting to imappro.zoho.com... +2026-01-12 12:43:13,795 - INFO - Login successful. Read-only mode: True +2026-01-12 12:43:13,795 - INFO - Searching INBOX for 'unsubscribe' keyword... +2026-01-12 12:43:14,010 - INFO - Found 448 emails containing 'unsubscribe'. Fetching data in batches... +2026-01-12 12:43:20,953 - INFO - Scan complete. Found 12 unique senders with links. +2026-01-12 12:43:39,244 - INFO - Logging in for deletion (Write Mode)... +2026-01-12 12:43:39,244 - INFO - Connecting to imappro.zoho.com... +2026-01-12 12:43:39,657 - INFO - Login successful. Read-only mode: False +2026-01-12 12:43:43,174 - INFO - Expunging 111 messages from server... +2026-01-12 12:43:43,314 - INFO - Expunge complete. +2026-01-12 12:43:45,563 - INFO - Exiting program. diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..01b7e09 --- /dev/null +++ b/uv.lock @@ -0,0 +1,92 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "autounsubscriber" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "imapclient" }, + { name = "pyzmail36" }, + { name = "tqdm" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.14.3" }, + { name = "imapclient", specifier = ">=3.0.1" }, + { name = "pyzmail36", specifier = ">=1.0.5" }, + { name = "tqdm", specifier = ">=4.67.1" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "imapclient" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/63/0eea51c9c263c18021cdc5866def55c98393f3bd74bbb8e3053e36f0f81a/IMAPClient-3.0.1.zip", hash = "sha256:78e6d62fbfbbe233e1f0e0e993160fd665eb1fd35973acddc61c15719b22bc02", size = 244222, upload-time = "2023-12-02T08:24:15.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/8a/d1364c1c6d8f53ea390e8f1c6da220a4f9ee478ac8a473ae0669a2fb6f51/IMAPClient-3.0.1-py2.py3-none-any.whl", hash = "sha256:d77d77caa4123e0233b5cf2b9c54a078522e63270b88d3f48653a28637fd8828", size = 182490, upload-time = "2023-12-02T08:24:11.854Z" }, +] + +[[package]] +name = "pyzmail36" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/02/a0fa81aaebba4e4ff5647fa6f96ec87b9ee16ea16580f2a0b60108537b2e/pyzmail36-1.0.5.tar.gz", hash = "sha256:835327fde6af722d0a4c6b313c94f7f40a7339b9aea12920ec1cf85498a7aeaf", size = 48308, upload-time = "2022-08-04T20:49:20.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/13/b91a5fc34f25e6b0b4f676674113d0f4eb6fc5bee4ef2e4c650e8b2de065/pyzmail36-1.0.5-py3-none-any.whl", hash = "sha256:ddacb71b78e7657a52624b63ebef0942600c6b08e35f1f481dd286e880e46cb2", size = 37073, upload-time = "2022-08-04T20:49:18.245Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From 8e9773c4e161670b54ffccde4f545aeff7b169ef Mon Sep 17 00:00:00 2001 From: Gunnar Date: Mon, 12 Jan 2026 12:48:39 -0600 Subject: [PATCH 2/2] Added gitignore --- .gitignore | 2 ++ unsubscriber.log | 13 ------------- 2 files changed, 2 insertions(+), 13 deletions(-) create mode 100644 .gitignore delete mode 100644 unsubscriber.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4b2e72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv +unsubscriber.log \ No newline at end of file diff --git a/unsubscriber.log b/unsubscriber.log deleted file mode 100644 index 3ce8a7d..0000000 --- a/unsubscriber.log +++ /dev/null @@ -1,13 +0,0 @@ -2026-01-12 12:43:00,098 - INFO - Starting AutoUnsubscriber... -2026-01-12 12:43:10,250 - INFO - Using manual provider: imappro.zoho.com -2026-01-12 12:43:13,352 - INFO - Connecting to imappro.zoho.com... -2026-01-12 12:43:13,795 - INFO - Login successful. Read-only mode: True -2026-01-12 12:43:13,795 - INFO - Searching INBOX for 'unsubscribe' keyword... -2026-01-12 12:43:14,010 - INFO - Found 448 emails containing 'unsubscribe'. Fetching data in batches... -2026-01-12 12:43:20,953 - INFO - Scan complete. Found 12 unique senders with links. -2026-01-12 12:43:39,244 - INFO - Logging in for deletion (Write Mode)... -2026-01-12 12:43:39,244 - INFO - Connecting to imappro.zoho.com... -2026-01-12 12:43:39,657 - INFO - Login successful. Read-only mode: False -2026-01-12 12:43:43,174 - INFO - Expunging 111 messages from server... -2026-01-12 12:43:43,314 - INFO - Expunge complete. -2026-01-12 12:43:45,563 - INFO - Exiting program.