Setting the file. One moment.
Read Mailcatcher · Reading Mailcatcher API · bitwarden/ai-plugins · Skills Docs
ContentsBack to the top of the page def attempt
— line 244
This file
Number 42.4
Position 4 of 8
Type Python
Size 13 KB
Lines 337 scripts/ read_mailcatcher.py
Python · 337 lines · 13 KB
16
the most recent message for the recipient.
17
18 The Mailcatcher endpoint is the same across Bitwarden dev environments, so the
19 base URL is fixed at http://localhost:1080 and cannot be overridden. Extracted
20 URLs are still restricted to a local dev allowlist; extend it (never replace it)
21 for a custom local hostname with the comma-separated MAILCATCHER_ALLOWED_HOSTS
22 env var.
23
24 Defaults:
25 --link-filter: verify|confirm|signup|token|trial|login|finish-signup
26
27 Designed to be called via the Bash tool. ../references/manual-api-walkthrough.md
28 documents the underlying Mailcatcher REST API this wraps.
29 """
30 import argparse
31 import html
32 import json
33 import os
34 import re
35 import sys
36 import time
37 import urllib.error
38 import urllib.parse
39 import urllib.request
40
41 DEFAULT_MAILCATCHER_URL = "http://localhost:1080"
42 DEFAULT_LINK_FILTER = "verify|confirm|signup|token|trial|login|finish-signup"
43 # Local dev hosts allowed for extracted URLs. Extend (never replace) via the
44 # comma-separated env var MAILCATCHER_ALLOWED_HOSTS.
45 DEFAULT_ALLOWED_HOSTS = ( "localhost" , "127.0.0.1" , "::1" , "bitwarden.test" )
46 # Whitespace terminates a URL. grep is line-based, so the bash original could
47 # never match across a newline; excluding all whitespace preserves that.
48 # Quotes and angle brackets end a URL inside an HTML attribute or tag, and a
49 # backslash ends one so a browser and urlparse never see different hosts.
50 URL_PATTERN = re.compile( r 'https ? :// [ ^ \s >" \' <) \\ ] + ' )
51 REQUEST_TIMEOUT = 5
52 RETRY_DELAY = 3
53
54 EXIT_OK = 0
55 EXIT_NO_MATCH = 1
56 EXIT_USAGE = 2
57 EXIT_UNREACHABLE = 3
58
59
60 class Unreachable ( Exception ):
61 """Mailcatcher could not be reached or returned something unparseable."""
62
63
64 class _NoRedirectHandler ( urllib . request . HTTPRedirectHandler ):
65 """Never follow 3xx redirects, matching curl's default (no -L).
66
67 The stdlib's default opener installs a HTTPRedirectHandler that follows a
68 301/302/303/307 Location header with no second check, which would let a
69 redirect return content from a host that was never validated. Returning
70 None from redirect_request tells the base handler not to redirect; it
71 then falls through to HTTPDefaultErrorHandler, which raises HTTPError for
72 the halted 3xx status. _http_get catches that HTTPError and returns the
73 3xx response's own body without ever requesting the Location target,
74 matching curl -fsS with no -L: -f only fails on status >= 400, so a
75 halted redirect is a success that yields the redirect response's body.
76 """
77
78 def redirect_request (self, req, fp, code, msg, headers, newurl):
79 return None
80
81
82 def allowed_hosts (env):
83 """Local dev hosts allowed for extracted URLs.
84
85 The defaults are extended (never replaced) by the comma-separated
86 MAILCATCHER_ALLOWED_HOSTS env var. That var is operator-supplied
87 (a real environment assignment, not model-constructed argv), so it stays
88 outside the auto-approved `script:*` Bash grant and is a safe way to widen
89 the allowlist for a custom local hostname.
90 """
91 hosts = [host.lower() for host in DEFAULT_ALLOWED_HOSTS ]
92 for entry in env.get( "MAILCATCHER_ALLOWED_HOSTS" , "" ).split( "," ):
93 entry = entry.strip().lower()
94 if entry:
95 hosts.append(entry)
96 return tuple (hosts)
97
98
99 def _http_get (url):
100 """GET url and return the body as text. Raises Unreachable on failure.
101
102 Mirrors curl -fsS with no -L: -f fails only on an HTTP status >= 400.
103 A halted 3xx (see _NoRedirectHandler) is not a failure under -f, so it
104 is treated the same as any other success and its own body is returned;
105 the Location target is never requested. Status >= 400 raises
106 Unreachable, as does any transport failure.
107
108 No TLS bypass here: the bash original used plain `curl -fsS` with no
109 `-k`, so an https base gets normal certificate verification. The base is
110 the fixed local http://localhost:1080 endpoint, not a caller-supplied
111 value, so there is no untrusted host to validate before the request.
112 """
113 opener = urllib.request.build_opener(_NoRedirectHandler)
114 try :
115 with opener.open(url, timeout = REQUEST_TIMEOUT ) as response:
116 return response.read().decode( "utf-8" , errors = "replace" )
117 except urllib.error.HTTPError as err:
118 if 300 <= err.code < 400 :
119 return err.read().decode( "utf-8" , errors = "replace" )
120 raise Unreachable( str (err))
121 except (urllib.error.URLError, OSError ) as err:
122 raise Unreachable( str (err))
123
124
125 def fetch_messages (base):
126 body = _http_get( f " { base } /messages" )
127 try :
128 messages = json.loads(body)
129 except ValueError as err:
130 raise Unreachable( f "invalid JSON from { base } /messages: { err } " )
131 if not isinstance (messages, list ):
132 raise Unreachable( f "expected a JSON array from { base } /messages" )
133 return messages
134
135
136 def select_message (messages, recipient, pattern):
137 """Id of the newest message matching the recipient and optional pattern.
138
139 A message whose `id` is missing or not an integer is dropped rather than
140 indexed into. Mailcatcher always sends an integer id, but keying `max` on
141 `msg["id"]` turned a missing id into a KeyError, and a mix of int and str
142 ids into a TypeError, both of which escaped this script's documented exit
143 codes; an unusable entry is simply not a candidate.
144 """
145 recipient = recipient.lower()
146 pattern = (pattern or "" ).lower()
147 matches = [
148 msg
149 for msg in messages
150 if isinstance (msg, dict )
151 and isinstance (msg.get( "id" ), int )
152 and not isinstance (msg.get( "id" ), bool )
153 and any (recipient in str (entry).lower() for entry in msg.get( "recipients" , []))
154 and ( not pattern or pattern in str (msg.get( "subject" , "" )).lower())
155 ]
156 if not matches:
157 return None
158 return max (matches, key =lambda msg: msg[ "id" ])[ "id" ]
159
160
161 def fetch_body (base, message_id):
162 """Return the message body, preferring plain text and falling back to HTML.
163
164 Distinguishes a genuinely empty plain part (Mailcatcher reachable, no plain
165 body) from an outage that strikes between the message-list fetch and the
166 body fetch. If BOTH the plain and HTML fetches fail to reach Mailcatcher,
167 raise Unreachable so the caller reports EXIT_UNREACHABLE rather than
168 mislabeling an outage as a link-filter miss.
169 """
170 try :
171 plain = _http_get( f " { base } /messages/ { message_id } .plain" )
172 except Unreachable:
173 plain = None
174 if plain:
175 return plain
176 if plain is not None :
177 # Reached Mailcatcher, but the plain part was empty: fall back to HTML.
178 print (
179 f "WARNING: plain body empty for message { message_id } ; "
180 "using HTML body for URL extraction" ,
181 file = sys.stderr,
182 )
183 # HTML href attributes carry entity-encoded ampersands (`&`) between
184 # query params. Unescaping here, on the HTML branch only, keeps the plain
185 # path byte-exact while making the extracted link usable rather than corrupt
186 # from the first `&` onward. Whitespace-terminated URL matching is
187 # unaffected: `&`/`%40` are not whitespace either way.
188 try :
189 return html.unescape(_http_get( f " { base } /messages/ { message_id } .html" ))
190 except Unreachable:
191 if plain is None :
192 # Neither part could be reached: an outage, not an empty body.
193 raise
194 return ""
195
196
197 def matching_urls (body, link_filter):
198 """Every URL in body matching link_filter, in document order.
199
200 The caller (attempt) applies the local-host preference on top of this: an
201 earlier non-local link that happens to match the filter (a marketing or
202 help footer link, say) must not mask a later local action link, so the
203 caller walks the full ordered list rather than taking the first match here.
204 """
205 try :
206 return [
207 candidate
208 for candidate in URL_PATTERN .findall(body or "" )
209 if re.search(link_filter, candidate, re. IGNORECASE )
210 ]
211 except re.error:
212 # Mirrors grep -iE with a malformed ERE: it errors out and yields no
213 # match rather than a stack trace, so this falls through to the
214 # caller's existing no-URL-found NO_MATCH branch.
215 return []
216
217
218 def _hostname (url):
219 """Lowercased hostname of url, or '' if it cannot be parsed."""
220 try :
221 return (urllib.parse.urlparse(url).hostname or "" ).lower()
222 except ValueError :
223 return ""
224
225
226 def is_local (url, allowed):
227 """Whether url points at an allowed local dev host.
228
229 Browsers end the authority at a backslash and urlparse does not, so
230 "https://evil.com \\ @localhost/" parses as localhost here but navigates to
231 evil.com. Dev email links carry neither a backslash nor userinfo, so either
232 one rejects the URL before the host is checked.
233 """
234 if " \\ " in url:
235 return False
236 try :
237 if "@" in urllib.parse.urlparse(url).netloc:
238 return False
239 except ValueError :
240 return False
241 return _hostname(url) in allowed
242
243
244 def attempt (base, recipient, pattern, link_filter, allowed):
245 """One pass. Returns (code, url_or_None).
246
247 code 0 with a URL on success; 1 when no message matched (retryable);
248 2 when a message matched but yielded no usable URL (not retryable);
249 3 when Mailcatcher is unreachable (not retryable).
250 """
251 try :
252 messages = fetch_messages(base)
253 except Unreachable as err:
254 print ( f "ERROR: Mailcatcher unreachable at { base } : { err } " , file = sys.stderr)
255 return 3 , None
256 message_id = select_message(messages, recipient, pattern)
257 if message_id is None :
258 return 1 , None
259 try :
260 body = fetch_body(base, message_id)
261 except Unreachable as err:
262 print ( f "ERROR: Mailcatcher unreachable at { base } : { err } " , file = sys.stderr)
263 return 3 , None
264 candidates = matching_urls(body, link_filter)
265 if not candidates:
266 print (
267 f "NO_MATCH: message { message_id } matched but contained no URL "
268 f "filtered by ' { link_filter } '" ,
269 file = sys.stderr,
270 )
271 return 2 , None
272 url = next ((c for c in candidates if is_local(c, allowed)), None )
273 if url is None :
274 rejected = sorted ({_hostname(c) for c in candidates} - { "" })
275 print (
276 f "NO_MATCH: none of the { len (candidates) } filter-matched URL(s) is a "
277 f "local dev host; rejected host(s): { ', ' .join(rejected) or '(unparseable)' } " ,
278 file = sys.stderr,
279 )
280 return 2 , None
281 return 0 , url
282
283
284 def main (argv, env):
285 parser = argparse.ArgumentParser(
286 prog = "read_mailcatcher.py" ,
287 description = "Print the first matching URL from a Mailcatcher message." ,
288 )
289 parser.add_argument( "--recipient" , required = True )
290 parser.add_argument( "--pattern" , default = "" )
291 parser.add_argument( "--link-filter" , default = DEFAULT_LINK_FILTER )
292 try :
293 args = parser.parse_args(argv)
294 except SystemExit as exc:
295 return exc.code if isinstance (exc.code, int ) else EXIT_USAGE
296
297 if not args.recipient.strip():
298 print ( "ERROR: --recipient is required" , file = sys.stderr)
299 return EXIT_USAGE
300
301 # The Mailcatcher endpoint is identical across Bitwarden dev environments,
302 # so the base URL is fixed here rather than taken from argv or the
303 # environment. Keeping it out of the auto-approved `script:*` argv shape
304 # means a granted call can never point the fetch at an arbitrary host.
305 base = DEFAULT_MAILCATCHER_URL
306 allowed = allowed_hosts(env)
307 settled = { 3 : EXIT_UNREACHABLE , 2 : EXIT_NO_MATCH }
308
309 code, url = attempt(base, args.recipient, args.pattern, args.link_filter, allowed)
310 if code == 0 :
311 print (url)
312 return EXIT_OK
313 if code in settled:
314 return settled[code]
315
316 # code was 1: Mailcatcher may not have received the message yet. Retry once.
317 time.sleep( RETRY_DELAY )
318 code, url = attempt(base, args.recipient, args.pattern, args.link_filter, allowed)
319 if code == 0 :
320 print (url)
321 return EXIT_OK
322 if code in settled:
323 return settled[code]
324
325 if args.pattern:
326 print (
327 f "NO_MATCH: no email for recipient ' { args.recipient } ' "
328 f "with subject containing ' { args.pattern } '" ,
329 file = sys.stderr,
330 )
331 else :
332 print ( f "NO_MATCH: no email for recipient ' { args.recipient } '" , file = sys.stderr)
333 return EXIT_NO_MATCH
334
335
336 if __name__ == "__main__" :
337 sys.exit(main(sys.argv[ 1 :], os.environ))