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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/email-code-finder.svg)](https://pypi.org/project/email-code-finder/)
30
+ [![Python](https://img.shields.io/pypi/pyversions/email-code-finder.svg)](https://pypi.org/project/email-code-finder/)
31
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ email_code_finder