@@ -1934,6 +1934,136 @@ def delete_mail_permanently_from_folder_type(self, folder_type: str, mail_uid: s
19341934 folder_path = self .folders_map_type_to_name [folder_type ]
19351935 self .delete_mails_by_uid (folder_path , mail_uid , move_to_trash = False , permanently = True )
19361936
1937+ def _search_uids_in_folder (self , folder_path : str , criteria : str ) -> str | None :
1938+ """Execute an IMAP SEARCH in a single folder and return the UID set string, or None if no results.
1939+
1940+ :param folder_path: IMAP folder path to search in.
1941+ :type folder_path: str
1942+ :param criteria: IMAP SEARCH criteria string.
1943+ :type criteria: str
1944+ :raises RequestException: If the SEARCH command fails.
1945+ :raises BugException: If not authenticated.
1946+ :return: Space-separated UID string, or None if no matches.
1947+ :rtype: str | None
1948+ """
1949+ if not folder_path .isascii ():
1950+ raise RequestException (f"Mailbox name is not ascii: { folder_path } " , err .ERROR_IMAP_NOT_ASCII )
1951+
1952+ try :
1953+ self .select_mailbox (folder_path , readonly = True )
1954+ except RequestException :
1955+ logger_imap .warning ("Folder '%s' not found or not selectable, skipping" , folder_path )
1956+ return None
1957+
1958+ success , datas = self ._exec_imap4_method (self .connection .uid , 'SEARCH' , criteria )
1959+ if not success :
1960+ raise RequestException (
1961+ f"IMAP SEARCH failed in folder '{ folder_path } ' with criteria: { criteria } " ,
1962+ err .ERROR_MAIL_SEARCH_FAILED
1963+ )
1964+
1965+ if not datas or not datas [0 ]:
1966+ return None
1967+
1968+ uid_set = datas [0 ].decode ().strip ()
1969+ return uid_set if uid_set else None
1970+
1971+ def search_mails_without_content (self , folders : list [str ], criteria : str ) -> Iterator [tuple [str , dict ]]:
1972+ """Execute an IMAP SEARCH with the given criteria string on each folder and
1973+ fetch the matching mails (headers only, no body content).
1974+
1975+ Yields tuples of (folder_path, mail_dict) for every matching mail across
1976+ all requested folders. ``mail_dict`` has the same shape as
1977+ ``_parse_mail_without_content_fetching`` output, enriched with
1978+ ``"folder"`` (the IMAP folder path).
1979+
1980+ :param folders: List of IMAP folder paths to search in.
1981+ :type folders: list[str]
1982+ :param criteria: IMAP SEARCH criteria string
1983+ :type criteria: str
1984+ :raises RequestException: If a SEARCH or FETCH command fails.
1985+ :raises BugException: If not authenticated.
1986+ :return: Yields (folder_path, mail_dict) tuples.
1987+ :rtype: Iterator[tuple[str, dict]]
1988+ """
1989+ logger_imap .debug ("Searching mails (without content) in folders %s with criteria: %s" , folders , criteria )
1990+ if self .connection is None or not self .authenticated :
1991+ raise BugException ("Not authenticated meaning self.connect() and self.login() was not called beforehands" )
1992+
1993+ for folder_path in folders :
1994+ uid_set = self ._search_uids_in_folder (folder_path , criteria )
1995+ if uid_set is None :
1996+ continue
1997+
1998+ # Fetch headers + bodystructure for all matching UIDs in one round-trip
1999+ success , fetch_datas = self ._exec_imap4_method (
2000+ self .connection .uid , 'FETCH' , uid_set .replace (' ' , ',' ),
2001+ '(BODY.PEEK[HEADER] BODYSTRUCTURE FLAGS UID RFC822.SIZE)'
2002+ )
2003+ if not success :
2004+ raise RequestException (
2005+ f"IMAP FETCH failed in folder '{ folder_path } ' for UIDs { uid_set } " ,
2006+ err .ERROR_MAIL_SEARCH_FAILED
2007+ )
2008+
2009+ for i in range (len (fetch_datas ) - 1 , - 1 , - 2 ):
2010+ pair = fetch_datas [i - 1 :i + 1 ]
2011+ if len (pair ) < 2 :
2012+ continue
2013+ bodystruct = pair [1 ]
2014+ message_parts = cast (tuple [bytes , bytes ], pair [0 ])
2015+ if not isinstance (message_parts , tuple ):
2016+ continue
2017+ has_attachment = self ._parse_body_structure_for_attachment (bodystruct )
2018+ mail_dict = self ._parse_mail_without_content_fetching (message_parts , has_attachment )
2019+ mail_dict ["folder" ] = folder_path
2020+ yield folder_path , mail_dict
2021+
2022+ def search_mails_with_content (self , folders : list [str ], criteria : str ) -> Iterator [tuple [str , dict ]]:
2023+ """Execute an IMAP SEARCH with the given criteria string on each folder and
2024+ fetch the matching mails with full body content.
2025+
2026+ Yields tuples of (folder_path, mail_dict) for every matching mail across
2027+ all requested folders. ``mail_dict`` has the same shape as
2028+ ``_parse_mail_with_content_fetching`` output, enriched with
2029+ ``"folder"`` (the IMAP folder path).
2030+
2031+ :param folders: List of IMAP folder paths to search in.
2032+ :type folders: list[str]
2033+ :param criteria: IMAP SEARCH criteria string
2034+ :type criteria: str
2035+ :raises RequestException: If a SEARCH or FETCH command fails.
2036+ :raises BugException: If not authenticated.
2037+ :return: Yields (folder_path, mail_dict) tuples.
2038+ :rtype: Iterator[tuple[str, dict]]
2039+ """
2040+ logger_imap .debug ("Searching mails (with content) in folders %s with criteria: %s" , folders , criteria )
2041+ if self .connection is None or not self .authenticated :
2042+ raise BugException ("Not authenticated meaning self.connect() and self.login() was not called beforehands" )
2043+
2044+ for folder_path in folders :
2045+ uid_set = self ._search_uids_in_folder (folder_path , criteria )
2046+ if uid_set is None :
2047+ continue
2048+
2049+ # Fetch full body for all matching UIDs in one round-trip
2050+ success , fetch_datas = self ._exec_imap4_method (
2051+ self .connection .uid , 'FETCH' , uid_set .replace (' ' , ',' ),
2052+ '(BODY.PEEK[] FLAGS UID)'
2053+ )
2054+ if not success :
2055+ raise RequestException (
2056+ f"IMAP FETCH failed in folder '{ folder_path } ' for UIDs { uid_set } " ,
2057+ err .ERROR_MAIL_SEARCH_FAILED
2058+ )
2059+
2060+ for part in fetch_datas :
2061+ if not isinstance (part , tuple ):
2062+ continue
2063+ mail_dict = self ._parse_mail_with_content_fetching (part )
2064+ mail_dict ["folder" ] = folder_path
2065+ yield folder_path , mail_dict
2066+
19372067 def logout (self ) -> None :
19382068 """
19392069 Log out from the IMAP server.
0 commit comments