One pattern, three inputs. Watch the ends — that is where all the difficulty is.
import re
PATTERN = r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
re.findall(PATTERN, 'write to [email protected].')
# -> ['[email protected]'] the sentence's full stop is not swallowed
re.findall(PATTERN, 'ada@localhost')
# -> [] no dot in the domain, so no match at all
re.findall(PATTERN, 'see <[email protected]>')
# -> ['[email protected]'] the brackets are not in either character class
- Backtracking is what stops the trailing full stop
The domain class contains a dot, so it greedily eats 'example.com.' first — then the pattern still needs a dot and two letters, fails, and gives characters back until it succeeds. The engine finds the longest match that satisfies the *whole* pattern, not the longest match of each piece. That is why an ending requirement acts as a boundary.
- The character classes are the definition of an address
Everything the pattern will accept is written in those two classes, and anything outside them — a bracket, a comma, a space, a quote — ends the match automatically. So the punctuation rules need no code: they are a consequence of saying precisely which characters count.
- This pattern is wrong, and that is a decision
It rejects valid addresses — quoted local parts, new long TLDs are fine but internationalised domains are not, plus-addressing survives only because + is in the class — and it accepts things no mail server would. The fully correct pattern from RFC 5322 is famously about 6,000 characters and still cannot tell you whether the mailbox exists. The only real validation is sending a message.
One findall gives you every match. Then lowercase them and remove the duplicates — in that order, or the two spellings of one address survive.
The pattern has three parts: the local characters, an @, and a domain that must end in a dot followed by at least two letters. That last requirement is what keeps a trailing full stop out of the match.
r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'. Compile it once at module level rather than inside the function. dict.fromkeys deduplicates while keeping first-seen order, in one call.
import re EMAIL = re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{____,}') def extract_emails(text): found = [address.____() for address in EMAIL.findall(text)] return list(dict.____(found))
import re
# Deliberately pragmatic: it finds the addresses people actually write.
# The pattern that matches the specification is thousands of characters
# long and still proves nothing about whether the mailbox exists.
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
def extract_emails(text):
found = [address.lower() for address in EMAIL.findall(text)]
return list(dict.fromkeys(found))
Deduplicating first leaves '[email protected]' and '[email protected]' as two entries, and lowercasing afterwards produces a list with the same string in it twice. Normalise, then compare — the same rule that makes tag lists and word counts behave, and the same order every time.
re.findall compiles and caches internally, so the difference is small — but naming the pattern gives it somewhere to live with a comment attached, which is what a pattern like this most needs. A regular expression buried in a call is a regular expression nobody will dare change.
It rules out a trailing single letter, which is what stops the match from running into the next word after a full stop, and it is the reason '[email protected]' is not found. It also, quietly, is why '[email protected].' loses its final stop — the engine backtracks to the last ending that satisfies it.
Never use this to decide whether an address is valid. Validation belongs to the email-validator library at best, and really to sending a confirmation message — every regular expression in this space either rejects real addresses or accepts unreal ones, usually both. If the text is a mail header rather than prose, email.utils.getaddresses parses it properly, including display names and quoting. Extraction from free text is what this is for, and even then treat the output as candidates.
- Return the addresses with their position in the text, so a caller can highlight them, and use finditer instead of findall.
- Add a domain filter so only addresses at a given company come back, and decide whether subdomains count.
- Feed it a page of real HTML and see what it finds inside href='mailto:' attributes — then decide whether that is a feature.