email-code-finder 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- email_code_finder/__init__.py +17 -0
- email_code_finder/client.py +167 -0
- email_code_finder/finder.py +170 -0
- email_code_finder/py.typed +0 -0
- email_code_finder-0.1.0.dist-info/METADATA +253 -0
- email_code_finder-0.1.0.dist-info/RECORD +8 -0
- email_code_finder-0.1.0.dist-info/WHEEL +5 -0
- email_code_finder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""email-code-finder: read one-time 2FA/OTP codes from an IMAP mailbox.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
from email_code_finder import EmailCodeFinder
|
|
6
|
+
|
|
7
|
+
finder = EmailCodeFinder(config_path="config.json", notify_callback=print)
|
|
8
|
+
code = finder.wait_for_code()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .client import ImapEmailClient
|
|
14
|
+
from .finder import EmailCodeFinder
|
|
15
|
+
|
|
16
|
+
__all__ = ["EmailCodeFinder", "ImapEmailClient"]
|
|
17
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""IMAP client used to search and read one-time authentication codes.
|
|
2
|
+
|
|
3
|
+
This module intentionally avoids any destructive mailbox operation. Messages
|
|
4
|
+
are never deleted; once a code is extracted the message is only flagged as
|
|
5
|
+
``\\Seen`` (read), so the user keeps a record of the email.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import email
|
|
11
|
+
import imaplib
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import ssl
|
|
15
|
+
from email.message import Message
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Default IMAP host/port for the most common providers. Any provider not listed
|
|
21
|
+
# here falls back to ``imap.<domain>`` derived from the user's email address.
|
|
22
|
+
DEFAULT_IMAP_PORT = 993
|
|
23
|
+
KNOWN_IMAP_SERVERS = {
|
|
24
|
+
"gmail": "imap.gmail.com",
|
|
25
|
+
"outlook": "outlook.office365.com",
|
|
26
|
+
"office365": "outlook.office365.com",
|
|
27
|
+
"yahoo": "imap.mail.yahoo.com",
|
|
28
|
+
"icloud": "imap.mail.me.com",
|
|
29
|
+
"kinghost": "imap.kinghost.net",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ImapEmailClient:
|
|
34
|
+
"""Thin wrapper around :mod:`imaplib` for reading authentication codes.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
user_email: Full email address used to authenticate.
|
|
38
|
+
password: Account or app-specific password. Prefer app passwords; see
|
|
39
|
+
the security notes in the project README.
|
|
40
|
+
provider: Optional provider key (``gmail``, ``outlook``, ``yahoo``,
|
|
41
|
+
``icloud``, ...). When omitted or unknown, the IMAP host is derived
|
|
42
|
+
from the email domain as ``imap.<domain>``.
|
|
43
|
+
imap_server: Explicit IMAP host. Overrides ``provider`` detection.
|
|
44
|
+
port: IMAP-over-SSL port. Defaults to 993.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
user_email: str,
|
|
50
|
+
password: str,
|
|
51
|
+
provider: Optional[str] = None,
|
|
52
|
+
imap_server: Optional[str] = None,
|
|
53
|
+
port: int = DEFAULT_IMAP_PORT,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.user_email = user_email
|
|
56
|
+
self.password = password
|
|
57
|
+
self.provider = provider.lower() if provider else None
|
|
58
|
+
self.port = port
|
|
59
|
+
self.imap_server = imap_server or self._resolve_imap_server()
|
|
60
|
+
self.mail: Optional[imaplib.IMAP4_SSL] = None
|
|
61
|
+
|
|
62
|
+
def _resolve_imap_server(self) -> str:
|
|
63
|
+
"""Return the IMAP host for the configured provider/email domain."""
|
|
64
|
+
if self.provider and self.provider in KNOWN_IMAP_SERVERS:
|
|
65
|
+
return KNOWN_IMAP_SERVERS[self.provider]
|
|
66
|
+
try:
|
|
67
|
+
domain = self.user_email.split("@", 1)[1].lower()
|
|
68
|
+
except IndexError as exc:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid email address, missing domain: {self.user_email!r}"
|
|
71
|
+
) from exc
|
|
72
|
+
return f"imap.{domain}"
|
|
73
|
+
|
|
74
|
+
def connect(self) -> None:
|
|
75
|
+
"""Open an authenticated IMAP-over-SSL session and select the inbox."""
|
|
76
|
+
context = ssl.create_default_context()
|
|
77
|
+
self.mail = imaplib.IMAP4_SSL(self.imap_server, self.port, ssl_context=context)
|
|
78
|
+
self.mail.login(self.user_email, self.password)
|
|
79
|
+
# readonly=False is required so processed messages can be flagged \Seen.
|
|
80
|
+
self.mail.select("INBOX", readonly=False)
|
|
81
|
+
|
|
82
|
+
def get_max_uid(self) -> int:
|
|
83
|
+
"""Return the highest UID currently in the inbox (0 if empty).
|
|
84
|
+
|
|
85
|
+
Used as a baseline: only messages with a UID greater than this value are
|
|
86
|
+
considered "new", so codes received before the wait started are ignored.
|
|
87
|
+
"""
|
|
88
|
+
self._require_connection()
|
|
89
|
+
status, data = self.mail.uid("SEARCH", None, "ALL")
|
|
90
|
+
if status != "OK":
|
|
91
|
+
raise RuntimeError(f"Failed to read inbox baseline: {status}")
|
|
92
|
+
uids = data[0].split()
|
|
93
|
+
return max((int(uid) for uid in uids), default=0)
|
|
94
|
+
|
|
95
|
+
def search_unread_by_subject(self, subject: str) -> List[bytes]:
|
|
96
|
+
"""Return UIDs of unread messages whose subject matches ``subject``.
|
|
97
|
+
|
|
98
|
+
Both the exact subject and a ``Fwd:`` forwarded variant are matched.
|
|
99
|
+
"""
|
|
100
|
+
self._require_connection()
|
|
101
|
+
subject_fwd = f"Fwd: {subject}"
|
|
102
|
+
criteria = '(OR (SUBJECT "{}") (SUBJECT "{}"))'.format(
|
|
103
|
+
subject, subject_fwd
|
|
104
|
+
).encode("utf-8")
|
|
105
|
+
status, messages = self.mail.uid(
|
|
106
|
+
"SEARCH", "CHARSET", "UTF-8", "UNSEEN", criteria
|
|
107
|
+
)
|
|
108
|
+
if status != "OK":
|
|
109
|
+
raise RuntimeError(f"Failed to search emails: {status}")
|
|
110
|
+
return messages[0].split()
|
|
111
|
+
|
|
112
|
+
def fetch_body(self, message_uid: bytes) -> str:
|
|
113
|
+
"""Fetch and decode the textual body (HTML preferred) of a message."""
|
|
114
|
+
self._require_connection()
|
|
115
|
+
status, data = self.mail.uid("FETCH", message_uid, "(RFC822)")
|
|
116
|
+
if status != "OK" or not data or data[0] is None:
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"Failed to fetch email UID {message_uid.decode(errors='ignore')}"
|
|
119
|
+
)
|
|
120
|
+
email_message = email.message_from_bytes(data[0][1])
|
|
121
|
+
return self._extract_text(email_message)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _extract_text(email_message: Message) -> str:
|
|
125
|
+
"""Return the best textual representation of the email body."""
|
|
126
|
+
html_body = ""
|
|
127
|
+
plain_body = ""
|
|
128
|
+
for part in email_message.walk():
|
|
129
|
+
if part.get_content_maintype() == "multipart":
|
|
130
|
+
continue
|
|
131
|
+
content_type = part.get_content_type()
|
|
132
|
+
payload = part.get_payload(decode=True)
|
|
133
|
+
if not payload:
|
|
134
|
+
continue
|
|
135
|
+
charset = part.get_content_charset() or "utf-8"
|
|
136
|
+
text = payload.decode(charset, errors="ignore")
|
|
137
|
+
if content_type == "text/html":
|
|
138
|
+
html_body = text
|
|
139
|
+
elif content_type == "text/plain":
|
|
140
|
+
plain_body = text
|
|
141
|
+
# Prefer HTML (codes are usually inside markup); fall back to plain text.
|
|
142
|
+
return html_body or plain_body
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def extract_code(body: str, pattern: str) -> Optional[str]:
|
|
146
|
+
"""Return the first capture group matched by ``pattern`` in ``body``."""
|
|
147
|
+
if not pattern:
|
|
148
|
+
raise ValueError("A non-empty regex pattern is required to extract the code.")
|
|
149
|
+
match = re.search(pattern, body, re.DOTALL)
|
|
150
|
+
return match.group(1) if match else None
|
|
151
|
+
|
|
152
|
+
def mark_as_read(self, message_uid: bytes) -> None:
|
|
153
|
+
"""Flag a message as read (``\\Seen``). The message is NOT deleted."""
|
|
154
|
+
self._require_connection()
|
|
155
|
+
self.mail.uid("STORE", message_uid, "+FLAGS", "(\\Seen)")
|
|
156
|
+
|
|
157
|
+
def logout(self) -> None:
|
|
158
|
+
"""Close the IMAP session if one is open."""
|
|
159
|
+
if self.mail is not None:
|
|
160
|
+
try:
|
|
161
|
+
self.mail.logout()
|
|
162
|
+
finally:
|
|
163
|
+
self.mail = None
|
|
164
|
+
|
|
165
|
+
def _require_connection(self) -> None:
|
|
166
|
+
if self.mail is None:
|
|
167
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""High-level API to wait for and extract a regex-matched value from email.
|
|
2
|
+
|
|
3
|
+
The most common use case is a one-time authentication code (2FA/OTP), but the
|
|
4
|
+
extraction is fully generic: whatever ``regex_pattern`` captures is returned,
|
|
5
|
+
be it a code, a confirmation link, a tracking number, or any other text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
from .client import ImapEmailClient
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Configuration keys that must be present for the finder to operate.
|
|
21
|
+
REQUIRED_CONFIG_KEYS = (
|
|
22
|
+
"user_email",
|
|
23
|
+
"password",
|
|
24
|
+
"subject_to_find",
|
|
25
|
+
"regex_pattern",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Defaults applied when the corresponding key is absent from the config.
|
|
29
|
+
DEFAULT_MAX_WAIT_SECONDS = 180
|
|
30
|
+
DEFAULT_CHECK_INTERVAL_SECONDS = 6
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EmailCodeFinder:
|
|
34
|
+
"""Poll an IMAP inbox until an email matching your regex arrives.
|
|
35
|
+
|
|
36
|
+
Returns whatever ``regex_pattern`` captures (group 1) — typically a 2FA/OTP
|
|
37
|
+
code, but any regex-matchable value works.
|
|
38
|
+
|
|
39
|
+
The finder is non-destructive: it never deletes emails. To avoid returning a
|
|
40
|
+
value from an older email, it records the highest inbox UID when the wait
|
|
41
|
+
starts and only considers messages received afterwards. The matched message
|
|
42
|
+
is flagged as read (``\\Seen``) once its value is extracted.
|
|
43
|
+
|
|
44
|
+
Timing note: the UID baseline is taken when the wait starts, so trigger the
|
|
45
|
+
action that sends the email *after* (or while) calling ``wait_for_code()`` —
|
|
46
|
+
an email already in the inbox is treated as stale and ignored.
|
|
47
|
+
|
|
48
|
+
Configuration (dict or JSON file)::
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
"provider": "gmail",
|
|
52
|
+
"user_email": "user@gmail.com",
|
|
53
|
+
"password": "app-password",
|
|
54
|
+
"subject_to_find": "Your verification code",
|
|
55
|
+
"regex_pattern": "(?s)token-2fa-text\\"?>.*?<b>(.*?)</b>.*?</div>",
|
|
56
|
+
"max_wait_time_seconds": 180,
|
|
57
|
+
"check_interval_seconds": 6
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
config: Configuration dict. If ``None``, it is loaded from ``config_path``.
|
|
62
|
+
config_path: Path to a JSON config file. Used only when ``config`` is None.
|
|
63
|
+
notify_callback: Optional ``Callable(message: str)`` invoked for
|
|
64
|
+
user-facing events (waiting, code found, timeout, error). Must be
|
|
65
|
+
thread-safe. When ``None``, events are only logged.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
config: Optional[dict] = None,
|
|
71
|
+
config_path: str = "config.json",
|
|
72
|
+
notify_callback: Optional[Callable[[str], None]] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.notify_callback = notify_callback
|
|
75
|
+
self.config = config if config is not None else self._load_config(config_path)
|
|
76
|
+
self._validate_config(self.config)
|
|
77
|
+
|
|
78
|
+
self.client = ImapEmailClient(
|
|
79
|
+
user_email=self.config["user_email"],
|
|
80
|
+
password=self.config["password"],
|
|
81
|
+
provider=self.config.get("provider"),
|
|
82
|
+
imap_server=self.config.get("imap_server"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _load_config(path: str) -> dict:
|
|
87
|
+
"""Load configuration from a JSON file."""
|
|
88
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
89
|
+
return json.load(handle)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _validate_config(config: dict) -> None:
|
|
93
|
+
"""Raise ``ValueError`` if any required configuration key is missing."""
|
|
94
|
+
missing = [key for key in REQUIRED_CONFIG_KEYS if not config.get(key)]
|
|
95
|
+
if missing:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"Missing required configuration key(s): " + ", ".join(missing)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _notify(self, message: str) -> None:
|
|
101
|
+
"""Log a message and forward it to ``notify_callback`` when present."""
|
|
102
|
+
log.info(message)
|
|
103
|
+
if self.notify_callback is not None:
|
|
104
|
+
try:
|
|
105
|
+
self.notify_callback(message)
|
|
106
|
+
except Exception as exc: # never let a UI callback break the flow
|
|
107
|
+
log.debug("notify_callback raised an exception: %s", exc)
|
|
108
|
+
|
|
109
|
+
def wait_for_code(self) -> Optional[str]:
|
|
110
|
+
"""Wait for and return the authentication code received by email.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The extracted code, or ``None`` if the timeout elapses first.
|
|
114
|
+
"""
|
|
115
|
+
max_wait = self.config.get("max_wait_time_seconds", DEFAULT_MAX_WAIT_SECONDS)
|
|
116
|
+
interval = self.config.get(
|
|
117
|
+
"check_interval_seconds", DEFAULT_CHECK_INTERVAL_SECONDS
|
|
118
|
+
)
|
|
119
|
+
subject = self.config["subject_to_find"]
|
|
120
|
+
regex_pattern = self.config["regex_pattern"]
|
|
121
|
+
|
|
122
|
+
code: Optional[str] = None
|
|
123
|
+
start_time = datetime.now()
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self.client.connect()
|
|
127
|
+
# Baseline: ignore every code that already exists in the inbox.
|
|
128
|
+
baseline_uid = self.client.get_max_uid()
|
|
129
|
+
self._notify("Connected to the mail server. Waiting for the email...")
|
|
130
|
+
|
|
131
|
+
while (datetime.now() - start_time).total_seconds() < max_wait:
|
|
132
|
+
for msg_uid in self.client.search_unread_by_subject(subject):
|
|
133
|
+
if int(msg_uid) <= baseline_uid:
|
|
134
|
+
continue # message predates this wait; skip it
|
|
135
|
+
body = self.client.fetch_body(msg_uid)
|
|
136
|
+
if not body:
|
|
137
|
+
continue
|
|
138
|
+
code = self.client.extract_code(body, regex_pattern)
|
|
139
|
+
if code:
|
|
140
|
+
self.client.mark_as_read(msg_uid)
|
|
141
|
+
log.debug("Email UID %s processed.", msg_uid.decode())
|
|
142
|
+
break
|
|
143
|
+
if code:
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
elapsed = (datetime.now() - start_time).total_seconds()
|
|
147
|
+
progress = int((elapsed / max_wait) * 100)
|
|
148
|
+
self._notify(
|
|
149
|
+
f"Waiting for the email... {progress}% ({elapsed:.0f}s)"
|
|
150
|
+
)
|
|
151
|
+
time.sleep(interval)
|
|
152
|
+
|
|
153
|
+
if code:
|
|
154
|
+
self._notify("Matching email received; value extracted.")
|
|
155
|
+
log.info("Value found.")
|
|
156
|
+
else:
|
|
157
|
+
self._notify(
|
|
158
|
+
f"Timed out. No matching email found within {max_wait}s."
|
|
159
|
+
)
|
|
160
|
+
log.warning("Timed out (%ss). No value found.", max_wait)
|
|
161
|
+
|
|
162
|
+
return code
|
|
163
|
+
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
self._notify(f"Error while waiting for the email: {exc}")
|
|
166
|
+
log.error("Error in wait_for_code: %s", exc, exc_info=True)
|
|
167
|
+
return None
|
|
168
|
+
finally:
|
|
169
|
+
self.client.logout()
|
|
170
|
+
log.info("Disconnected from the mail server.")
|
|
File without changes
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: email-code-finder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Extract any regex-matchable value (2FA/OTP codes, links, tokens, text) from an IMAP mailbox without deleting messages.
|
|
5
|
+
Author-email: Erik Melias <erikmelias@phac.com.br>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/erikmelias/email-code-finder
|
|
8
|
+
Project-URL: Repository, https://github.com/erikmelias/email-code-finder
|
|
9
|
+
Project-URL: Issues, https://github.com/erikmelias/email-code-finder/issues
|
|
10
|
+
Keywords: imap,email,regex,extract,scraping,2fa,otp,authentication,token,code
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Communications :: Email
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# email-code-finder
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/email-code-finder/)
|
|
30
|
+
[](https://pypi.org/project/email-code-finder/)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
|
|
33
|
+
Wait for an email to arrive in an IMAP mailbox and extract **any value you can
|
|
34
|
+
describe with a regular expression** — a 2FA/OTP code, a confirmation link, a
|
|
35
|
+
tracking number, an order ID, a token, or any piece of text. The library
|
|
36
|
+
connects over IMAP, waits for the message whose subject you specify, runs your
|
|
37
|
+
regex against the body, and returns the captured value — **without ever deleting
|
|
38
|
+
your messages.**
|
|
39
|
+
|
|
40
|
+
> **Not just 2FA.** One-time codes are the most common use case, but the engine
|
|
41
|
+
> is generic: if the value is somewhere in the email body and a regex can
|
|
42
|
+
> capture it, this library can fetch it. The `regex_pattern` you provide is the
|
|
43
|
+
> only thing that decides *what* gets extracted.
|
|
44
|
+
|
|
45
|
+
Examples of what you can extract:
|
|
46
|
+
|
|
47
|
+
| Goal | Example `subject_to_find` | Example `regex_pattern` |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| 2FA / OTP code | `Your verification code` | `\b(\d{6})\b` |
|
|
50
|
+
| Confirmation link | `Confirm your email` | `href="(https://[^"]*/confirm[^"]*)"` |
|
|
51
|
+
| Order / tracking ID | `Your order shipped` | `Tracking:\s*([A-Z0-9]{10,})` |
|
|
52
|
+
| Arbitrary text | `Your invoice` | `Invoice No\.\s*(\S+)` |
|
|
53
|
+
|
|
54
|
+
- **No third-party dependencies** — standard library only.
|
|
55
|
+
- **Non-destructive** — emails are flagged as read, never deleted.
|
|
56
|
+
- **Provider-aware** — Gmail, Outlook/Office 365, Yahoo, iCloud, or any custom
|
|
57
|
+
IMAP host.
|
|
58
|
+
- **Pluggable notifications** — pass a callback to surface progress in your UI.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install email-code-finder
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Requires Python 3.8+.
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from email_code_finder import EmailCodeFinder
|
|
74
|
+
|
|
75
|
+
config = {
|
|
76
|
+
"provider": "gmail",
|
|
77
|
+
"user_email": "user@example.com",
|
|
78
|
+
"password": "your-app-specific-password",
|
|
79
|
+
"subject_to_find": "Your verification code",
|
|
80
|
+
"regex_pattern": r"(?s)token-2fa-text\"?>.*?<b>(.*?)</b>.*?</div>",
|
|
81
|
+
"max_wait_time_seconds": 180,
|
|
82
|
+
"check_interval_seconds": 6,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
finder = EmailCodeFinder(config=config, notify_callback=print)
|
|
86
|
+
code = finder.wait_for_code()
|
|
87
|
+
|
|
88
|
+
if code:
|
|
89
|
+
print(f"Got the code: {code}")
|
|
90
|
+
else:
|
|
91
|
+
print("Timed out without receiving a code.")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or load the configuration from a JSON file:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
finder = EmailCodeFinder(config_path="config.json")
|
|
98
|
+
code = finder.wait_for_code()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A runnable example lives in [`examples/basic_usage.py`](examples/basic_usage.py).
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Configuration can be passed as a `dict` or stored in a JSON file (see
|
|
106
|
+
[`examples/config.example.json`](examples/config.example.json)).
|
|
107
|
+
|
|
108
|
+
| Key | Type | Required | Description |
|
|
109
|
+
| --- | --- | --- | --- |
|
|
110
|
+
| `user_email` | str | ✅ | Full email address used to authenticate. |
|
|
111
|
+
| `password` | str | ✅ | Account or **app-specific** password (see Security). |
|
|
112
|
+
| `subject_to_find` | str | ✅ | Subject line of the email carrying the code. A `Fwd:` variant is matched too. |
|
|
113
|
+
| `regex_pattern` | str | ✅ | Regex whose **first capture group** is the code. |
|
|
114
|
+
| `provider` | str | ➖ | `gmail`, `outlook`, `office365`, `yahoo`, `icloud`, `kinghost`. If omitted, the host is derived from your email domain (`imap.<domain>`). |
|
|
115
|
+
| `imap_server` | str | ➖ | Explicit IMAP host; overrides `provider` detection. |
|
|
116
|
+
| `max_wait_time_seconds` | int | ➖ | How long to wait before giving up. Default `180`. |
|
|
117
|
+
| `check_interval_seconds` | int | ➖ | Delay between inbox polls. Default `6`. |
|
|
118
|
+
|
|
119
|
+
### Writing the regex
|
|
120
|
+
|
|
121
|
+
`regex_pattern` is matched with `re.DOTALL`; the value returned is **capture
|
|
122
|
+
group 1**. For a code wrapped in `<b>123456</b>` you might use:
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
<b>(\d{6})</b>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Test your pattern against a real email body before relying on it.
|
|
129
|
+
|
|
130
|
+
## API
|
|
131
|
+
|
|
132
|
+
### `EmailCodeFinder(config=None, config_path="config.json", notify_callback=None)`
|
|
133
|
+
|
|
134
|
+
- `config` — configuration dict. When `None`, it is loaded from `config_path`.
|
|
135
|
+
- `config_path` — path to a JSON config file (used only when `config` is `None`).
|
|
136
|
+
- `notify_callback` — optional `Callable(message: str)` invoked for user-facing
|
|
137
|
+
events (waiting, code found, timeout, error). Must be thread-safe.
|
|
138
|
+
|
|
139
|
+
Missing required keys raise `ValueError`.
|
|
140
|
+
|
|
141
|
+
#### `wait_for_code() -> Optional[str]`
|
|
142
|
+
|
|
143
|
+
Connects, polls the inbox until the code arrives or the timeout elapses, and
|
|
144
|
+
returns the code (or `None`). On success the matching email is flagged as read.
|
|
145
|
+
The connection is always closed when the call returns.
|
|
146
|
+
|
|
147
|
+
### `ImapEmailClient`
|
|
148
|
+
|
|
149
|
+
Lower-level IMAP wrapper exposed for advanced use (`connect`, `get_max_uid`,
|
|
150
|
+
`search_unread_by_subject`, `fetch_body`, `extract_code`, `mark_as_read`,
|
|
151
|
+
`logout`). It performs **no destructive operations**.
|
|
152
|
+
|
|
153
|
+
## How matching works: timing & the UID baseline
|
|
154
|
+
|
|
155
|
+
Understanding the sequence is important to use the library correctly.
|
|
156
|
+
|
|
157
|
+
1. **Baseline.** The moment `wait_for_code()` connects, it reads the highest
|
|
158
|
+
existing message UID in the inbox and stores it as a *baseline*. IMAP UIDs
|
|
159
|
+
only ever increase, so this is a precise "everything up to here is old" mark.
|
|
160
|
+
2. **Polling.** Every `check_interval_seconds` it searches the inbox for
|
|
161
|
+
**unread** messages whose subject matches `subject_to_find` (a `Fwd:`
|
|
162
|
+
variant is matched too).
|
|
163
|
+
3. **New-only filter.** Any match with a UID **less than or equal to** the
|
|
164
|
+
baseline is skipped — it was already there before you started waiting, so it
|
|
165
|
+
is treated as stale. Only genuinely new messages are inspected.
|
|
166
|
+
4. **Extraction.** The regex runs against the body of each new match. The first
|
|
167
|
+
message that yields a capture group wins: that value is returned and the
|
|
168
|
+
message is flagged as read.
|
|
169
|
+
5. **Timeout.** If nothing matches within `max_wait_time_seconds`, the call
|
|
170
|
+
returns `None`.
|
|
171
|
+
|
|
172
|
+
This UID baseline replaces the old, dangerous behaviour of *deleting* the inbox
|
|
173
|
+
to "clean up" previous codes. Your existing emails are never touched.
|
|
174
|
+
|
|
175
|
+
### ⏱️ Critical: start waiting *before* the email is sent
|
|
176
|
+
|
|
177
|
+
Because the baseline is taken at the start, an email that arrives **before** you
|
|
178
|
+
call `wait_for_code()` will be at or below the baseline and therefore ignored.
|
|
179
|
+
Trigger the action that generates the email **after** (or concurrently with)
|
|
180
|
+
starting the wait:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import threading
|
|
184
|
+
from email_code_finder import EmailCodeFinder
|
|
185
|
+
|
|
186
|
+
finder = EmailCodeFinder(config=config)
|
|
187
|
+
|
|
188
|
+
# Run wait_for_code() first (in a thread), THEN trigger the email.
|
|
189
|
+
result = {}
|
|
190
|
+
waiter = threading.Thread(target=lambda: result.update(code=finder.wait_for_code()))
|
|
191
|
+
waiter.start()
|
|
192
|
+
|
|
193
|
+
trigger_login() # the action that makes the provider send the email
|
|
194
|
+
waiter.join()
|
|
195
|
+
print(result["code"])
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Tuning the delays
|
|
199
|
+
|
|
200
|
+
| Setting | What it controls | Guidance |
|
|
201
|
+
| --- | --- | --- |
|
|
202
|
+
| `max_wait_time_seconds` | Total time to wait before giving up. | Set it above the worst-case email delivery time. Mail can take anywhere from a few seconds to a couple of minutes; `180` (3 min) is a safe default. |
|
|
203
|
+
| `check_interval_seconds` | Pause between inbox polls. | Lower = the code is picked up sooner, but more IMAP requests. `6` is a good balance. Avoid going below `2–3` so you don't hit provider rate limits or get your IP throttled. |
|
|
204
|
+
|
|
205
|
+
The call returns **as soon as** a matching code is found — the interval is only
|
|
206
|
+
the upper bound on how long after arrival you notice it, not a fixed wait.
|
|
207
|
+
|
|
208
|
+
## Security
|
|
209
|
+
|
|
210
|
+
> ⚠️ **This library handles mailbox credentials. Read this section.**
|
|
211
|
+
|
|
212
|
+
- **Use app-specific passwords**, not your main account password. Gmail,
|
|
213
|
+
Outlook, Yahoo and iCloud all support them and most require them when 2FA is
|
|
214
|
+
enabled on the account.
|
|
215
|
+
- **Never commit `config.json`.** It is listed in `.gitignore`. Prefer loading
|
|
216
|
+
secrets from environment variables or a secret manager in production, e.g.:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
import os
|
|
220
|
+
config["password"] = os.environ["EMAIL_PASSWORD"]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- **Connections use TLS** (`IMAP4_SSL` on port 993) with certificate
|
|
224
|
+
verification via `ssl.create_default_context()`. Do not disable verification.
|
|
225
|
+
- **Least privilege.** If your provider supports it, use a dedicated mailbox or
|
|
226
|
+
an account scoped only to receiving these codes.
|
|
227
|
+
- **Logging.** Extracted codes are written to logs at `DEBUG`/`INFO` level for
|
|
228
|
+
troubleshooting. Keep your log level and log storage appropriately restricted,
|
|
229
|
+
and avoid `DEBUG` in production if logs are shared.
|
|
230
|
+
- **Regex from untrusted input.** If `regex_pattern` ever comes from an
|
|
231
|
+
untrusted source, beware of catastrophic backtracking (ReDoS). Prefer simple,
|
|
232
|
+
anchored patterns.
|
|
233
|
+
|
|
234
|
+
## Limitations
|
|
235
|
+
|
|
236
|
+
- IMAP only; POP3 and provider-specific APIs are not supported.
|
|
237
|
+
- Reads from `INBOX` only.
|
|
238
|
+
- The code must be extractable from the email body via a single regex group.
|
|
239
|
+
|
|
240
|
+
## Development
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
git clone https://github.com/erikmelias/email-code-finder.git
|
|
244
|
+
cd email-code-finder
|
|
245
|
+
pip install -e ".[dev]"
|
|
246
|
+
pytest
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Tests use a mocked IMAP client and make no network connections.
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
[MIT](LICENSE) © Erik Melias
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
email_code_finder/__init__.py,sha256=OGgg2_dgVwIoMpUeJ0b0Ft2LA1KtqMhJymvUCinmpsk,436
|
|
2
|
+
email_code_finder/client.py,sha256=1YDUHuyeJS19pC48fW_jp_JxERMhQmD7YyF0UEHMp4Y,6657
|
|
3
|
+
email_code_finder/finder.py,sha256=QRxzMYw9u3vGIgNcqrNpW0l7gzrUcUNdsLsdyM9Ox6Y,6582
|
|
4
|
+
email_code_finder/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
email_code_finder-0.1.0.dist-info/METADATA,sha256=cEZo1KeSeDyaHUhV-60FNd-ycr-yX1y2SV8LjeAa6jM,10500
|
|
6
|
+
email_code_finder-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
email_code_finder-0.1.0.dist-info/top_level.txt,sha256=cb9vapanMPCv8AEe75_iLRuhJ0qg_wyLvLzoRLwftwk,18
|
|
8
|
+
email_code_finder-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
email_code_finder
|