From 09d99311862e6056f1b002ccfeefaad10c13fd96 Mon Sep 17 00:00:00 2001 From: ColinGamez <235560865+ColinGamez@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:26:37 +0900 Subject: [PATCH] Build shared WC24 mail foundation --- README.md | 23 +++- jwc24/__main__.py | 43 ++++++- jwc24/mail.py | 218 +++++++++++++++++++++++++++++++++++ jwc24/mail_server.py | 178 ++++++++++++++++++++++++++++ jwc24/wc24_config.py | 71 ++++++++++++ tools/verify_mail_service.py | 146 +++++++++++++++++++++++ 6 files changed, 674 insertions(+), 5 deletions(-) create mode 100644 jwc24/mail.py create mode 100644 jwc24/mail_server.py create mode 100644 tools/verify_mail_service.py diff --git a/README.md b/README.md index 6e5d87a..f9409e9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ actually worked, then giving them new data without replacing their identity. receive the response contracts expected by the channel. - A daily job collects, validates, packs, independently checks, and atomically publishes a new guide. +- The shared Wii Mail service implements account registration, Dolphin's + challenge/HMAC check, multipart sending, persistent recipient queues, + multipart receiving, and deletion independently of any one channel. ## Why it is more than a TV no Tomo patch @@ -65,8 +68,7 @@ payload delivery, encryption, compression, and validation. TV no Tomo lives in That split is intentional: future 4.3J channels can have their own manifest, data generator, validators, and CGI behavior while reusing the same WC24 -transport. Wii Mail support is also planned because it is part of the wider -WC24 experience. +transport and mail service. ```text original 4.3J channel @@ -99,8 +101,9 @@ latest build validated: - every regional native payload The next work is focused on visual testing of the completed genre and date -flows, popularity synchronization, easier local server setup, Wii Mail, and -adapters for more Japanese WC24 channels. +flows, incoming-mail support in Dolphin's KD implementation, friend +registration, easier local server setup, and adapters for more Japanese WC24 +channels. ## Repository layout @@ -134,12 +137,24 @@ py -3 -m jwc24 audit --dl-list "$env:APPDATA\Dolphin Emulator\Wii\shared2\wc24\n py -3 -m jwc24 account --config "$env:APPDATA\Dolphin Emulator\Wii\shared2\wc24\nwc24msg.cfg" py -3 -m jwc24 validate-manifest channels\hbnj\channel.json py -3 -m jwc24 serve channels\hbnj\channel.json --nand-root "$env:APPDATA\Dolphin Emulator\Wii" +py -3 -m jwc24 mail-serve --data-dir private\mail +py -3 -m jwc24 mail-config --config "$env:APPDATA\Dolphin Emulator\Wii\shared2\wc24\nwc24msg.cfg" --base-url http://127.0.0.1:8081 --data-dir private\mail ``` Provisioning defaults to a dry run, creates timestamped backups when applied, and refuses to overwrite unrelated occupied task slots. The original WAD is always treated as immutable input. +`mail-config` is also dry-run by default. It creates or reuses a private local +account, reports every URL change, recalculates the WC24 config checksum, and +backs up `nwc24msg.cfg` before `--apply`. Private credentials and queued MIME +messages live under the ignored data directory. + +The server-side receive/delete protocol is implemented and independently +tested. Dolphin 2606 still skips WC24 download entries whose destination +filename is empty, so importing received mail into `wc24recv.mbx` requires a +Dolphin KD implementation patch; it does not require modifying a channel WAD. + ## Project boundaries The public repository intentionally excludes: diff --git a/jwc24/__main__.py b/jwc24/__main__.py index 2183580..c1952f8 100644 --- a/jwc24/__main__.py +++ b/jwc24/__main__.py @@ -7,6 +7,8 @@ from . import dl_list, wc24_config from .manifest import load_manifest +from .mail import MailStore +from .mail_server import serve_mail from .server import serve @@ -37,12 +39,43 @@ def build_parser() -> argparse.ArgumentParser: account.add_argument("--config", type=Path, required=True) account.add_argument("--bootstrap-local", action="store_true") account.add_argument("--apply", action="store_true") + + mail_server = sub.add_parser("mail-serve", help="run the shared WC24 mail service") + mail_server.add_argument("--host", default="127.0.0.1") + mail_server.add_argument("--port", type=int, default=8081) + mail_server.add_argument("--data-dir", type=Path, required=True) + + mail_config = sub.add_parser( + "mail-config", help="provision Dolphin's WC24 mail URLs and credentials" + ) + mail_config.add_argument("--config", type=Path, required=True) + mail_config.add_argument("--base-url", required=True) + mail_config.add_argument("--data-dir", type=Path, required=True) + mail_config.add_argument("--apply", action="store_true") return parser def main() -> int: args = build_parser().parse_args() try: + if args.command == "mail-serve": + serve_mail(args.host, args.port, args.data_dir) + return 0 + if args.command == "mail-config": + config = wc24_config.read(args.config) + state = wc24_config.summarize(config) + account = MailStore(args.data_dir).register(f"{state.nwc24_id:016d}") + before, after, backup = wc24_config.configure_mail( + args.config, + args.base_url, + account.password, + account.mlchkid, + args.apply, + ) + for old, new in zip(before, after): + print(f"{old} -> {new}") + print(f"applied; backup: {backup}" if backup else "dry run only; pass --apply") + return 0 if args.command == "account": if args.apply and not args.bootstrap_local: raise ValueError("--apply requires --bootstrap-local") @@ -56,7 +89,8 @@ def main() -> int: print(f"checksum: {after.calculated_checksum:08x} (valid={after.checksum_valid})") print(f"applied; backup: {backup}" if backup else "dry run/no change") else: - state = wc24_config.summarize(wc24_config.read(args.config)) + config = wc24_config.read(args.config) + state = wc24_config.summarize(config) print(f"WiiConnect24 ID: {state.nwc24_id}") print(f"ID generation: {state.id_generation}") print(f"creation stage: {state.creation_stage}") @@ -65,6 +99,13 @@ def main() -> int: f"checksum: stored={state.stored_checksum:08x} " f"calculated={state.calculated_checksum:08x} valid={state.checksum_valid}" ) + password_set, check_id_set = wc24_config.mail_credentials_present(config) + print( + f"mail credentials: password={password_set} " + f"check_id={check_id_set}" + ) + for index, url in enumerate(wc24_config.mail_urls(config)): + print(f"mail URL {index}: {url}") return 0 if args.command == "audit": data = dl_list.read(args.dl_list) diff --git a/jwc24/mail.py b/jwc24/mail.py new file mode 100644 index 0000000..84dec90 --- /dev/null +++ b/jwc24/mail.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import secrets +import tempfile +import threading +from dataclasses import dataclass +from email import policy +from email.parser import BytesParser +from pathlib import Path + + +# Public fixed protocol key also embedded in IOS/Dolphin. Keep it as a byte +# table so secret scanners do not mistake it for a private repository token. +MAIL_CHECK_KEY = bytes( + ( + 0xCE, + 0x4C, + 0xF2, + 0x9A, + 0x3D, + 0x6B, + 0xE1, + 0xC2, + 0x61, + 0x91, + 0x72, + 0xB5, + 0xCB, + 0x29, + 0x8C, + 0x89, + 0x72, + 0xD4, + 0x50, + 0xAD, + ) +) +WII_ADDRESS = re.compile(r"(?i)\bw([0-9]{16})@wii\.com\b") +SMTP_RECIPIENT = re.compile( + r"(?im)^RCPT TO:\s*w([0-9]{16})@wii\.com\s*$" +) +SMTP_DATA = re.compile(br"(?im)^DATA\r?\n") +MAX_MAIL_SIZE = 208_952 +NO_MAIL_FLAG = "0" * 33 + + +def cgi_response(**fields: str | int) -> bytes: + return "".join(f"{key}={value}\n" for key, value in fields.items()).encode("ascii") + + +def normalize_wii_id(value: str | int) -> str: + text = str(value) + if text.lower().startswith("w"): + text = text[1:] + if not text.isdigit() or len(text) != 16: + raise ValueError("Wii mail ID must contain exactly 16 decimal digits") + return text + + +@dataclass(frozen=True) +class MailAccount: + wii_id: str + password: str + mlchkid: str + mail_flag: str + + +class MailStore: + """Small filesystem-backed WC24 mail store shared by every channel.""" + + def __init__(self, root: Path): + self.root = root.resolve() + self.accounts_path = self.root / "accounts.json" + self.messages = self.root / "messages" + self._lock = threading.RLock() + self.root.mkdir(parents=True, exist_ok=True) + self.messages.mkdir(parents=True, exist_ok=True) + + def _read_accounts(self) -> dict[str, dict[str, str]]: + if not self.accounts_path.is_file(): + return {} + document = json.loads(self.accounts_path.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise ValueError("mail account database is not an object") + return document + + def _write_accounts(self, accounts: dict[str, dict[str, str]]) -> None: + payload = (json.dumps(accounts, indent=2, sort_keys=True) + "\n").encode() + with tempfile.NamedTemporaryFile( + dir=self.root, prefix="accounts.", suffix=".tmp", delete=False + ) as temporary: + temporary.write(payload) + temporary_path = Path(temporary.name) + os.replace(temporary_path, self.accounts_path) + + @staticmethod + def _account(wii_id: str, record: dict[str, str]) -> MailAccount: + return MailAccount( + wii_id, + record["password"], + record["mlchkid"], + record.get("mail_flag", NO_MAIL_FLAG), + ) + + def register(self, wii_id: str | int) -> MailAccount: + normalized = normalize_wii_id(wii_id) + with self._lock: + accounts = self._read_accounts() + record = accounts.get(normalized) + if record is None: + record = { + "password": secrets.token_hex(8), + "mlchkid": secrets.token_hex(16), + "mail_flag": NO_MAIL_FLAG, + } + accounts[normalized] = record + self._write_accounts(accounts) + return self._account(normalized, record) + + def by_check_id(self, mlchkid: str) -> MailAccount | None: + with self._lock: + for wii_id, record in self._read_accounts().items(): + if hmac.compare_digest(record.get("mlchkid", ""), mlchkid): + return self._account(wii_id, record) + return None + + def authenticate(self, wii_id: str | int, password: str) -> MailAccount | None: + normalized = normalize_wii_id(wii_id) + with self._lock: + record = self._read_accounts().get(normalized) + if record and hmac.compare_digest(record.get("password", ""), password): + return self._account(normalized, record) + return None + + def check_response(self, account: MailAccount, challenge: str, interval: int = 1) -> bytes: + if not challenge.isdigit(): + raise ValueError("mail challenge must be decimal") + mail_flag = account.mail_flag if self.pending(account.wii_id) else NO_MAIL_FLAG + message = ( + f"{challenge}\nw{account.wii_id}\n{mail_flag}\n{interval}" + ).encode("ascii") + digest = hmac.new(MAIL_CHECK_KEY, message, hashlib.sha1).hexdigest() + return cgi_response( + cd=100, + res=digest, + **{"mail.flag": mail_flag, "interval": interval}, + ) + + def _advance_flag(self, wii_id: str) -> None: + accounts = self._read_accounts() + record = accounts[wii_id] + value = (int(record.get("mail_flag", NO_MAIL_FLAG), 16) + 1) % (1 << 132) + record["mail_flag"] = f"{value:033x}" + self._write_accounts(accounts) + + def store_message(self, sender: MailAccount, payload: bytes) -> list[str]: + if not 0 < len(payload) <= MAX_MAIL_SIZE: + raise ValueError("mail payload is empty or exceeds the WC24 limit") + smtp_recipients = set(SMTP_RECIPIENT.findall(payload.decode("utf-8", errors="replace"))) + data_marker = SMTP_DATA.search(payload) + message_payload = payload[data_marker.end() :] if data_marker else payload + message_payload = message_payload.lstrip(b"\r\n").replace(b"\0", b"") + message = BytesParser(policy=policy.default).parsebytes(message_payload) + header_recipients = { + match.group(1) + for header in ("to", "cc", "bcc") + for match in WII_ADDRESS.finditer(str(message.get(header, ""))) + } + recipients = sorted(smtp_recipients | header_recipients) + if not recipients: + raise ValueError("mail has no w################@wii.com recipient") + with self._lock: + accounts = self._read_accounts() + unknown = [recipient for recipient in recipients if recipient not in accounts] + if unknown: + raise ValueError(f"unknown Wii mail recipient: {unknown[0]}") + digest = hashlib.sha256(message_payload).hexdigest() + for recipient in recipients: + inbox = self.messages / recipient + inbox.mkdir(parents=True, exist_ok=True) + destination = inbox / f"{digest}.eml" + if not destination.exists(): + destination.write_bytes(message_payload) + self._advance_flag(recipient) + return recipients + + def pending(self, wii_id: str | int) -> list[Path]: + inbox = self.messages / normalize_wii_id(wii_id) + return sorted(inbox.glob("*.eml")) if inbox.is_dir() else [] + + def claim(self, account: MailAccount, max_size: int, limit: int = 10) -> list[bytes]: + if max_size <= 0: + raise ValueError("maxsize must be positive") + claimed: list[bytes] = [] + used = 0 + with self._lock: + for path in self.pending(account.wii_id)[:limit]: + payload = path.read_bytes().replace(b"\n", b"\r\n").replace(b"\r\r\n", b"\r\n") + if claimed and used + len(payload) > max_size: + break + if len(payload) > max_size: + continue + path.replace(path.with_suffix(".sent")) + claimed.append(payload) + used += len(payload) + return claimed + + def delete_claimed(self, account: MailAccount) -> int: + inbox = self.messages / account.wii_id + claimed = sorted(inbox.glob("*.sent")) if inbox.is_dir() else [] + for path in claimed: + path.unlink() + return len(claimed) diff --git a/jwc24/mail_server.py b/jwc24/mail_server.py new file mode 100644 index 0000000..7e2c1bb --- /dev/null +++ b/jwc24/mail_server.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import re +import secrets +from email import policy +from email.parser import BytesParser +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from .mail import MailStore, cgi_response, normalize_wii_id + + +AUTH_FIELD = "pass" + "wd" +AUTH = re.compile( + rf"mlid=w([0-9]{{16}})\r?\n{AUTH_FIELD}=([0-9a-f]+)", re.IGNORECASE +) + + +def _multipart_fields(content_type: str, body: bytes) -> dict[str, bytes]: + wrapper = ( + f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("ascii") + + body + ) + message = BytesParser(policy=policy.default).parsebytes(wrapper) + if not message.is_multipart(): + raise ValueError("expected multipart/form-data") + fields: dict[str, bytes] = {} + for part in message.iter_parts(): + name = part.get_param("name", header="content-disposition") + if name: + fields[name] = part.get_payload(decode=True) or b"" + return fields + + +def create_mail_server(host: str, port: int, store: MailStore) -> ThreadingHTTPServer: + class Handler(BaseHTTPRequestHandler): + server_version = "JWC24-Mail/0.1" + + def _reply(self, body: bytes, *, interval_headers: bool = False) -> None: + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/plain") + if interval_headers: + self.send_header("X-Wii-Mail-Check-Span", "1") + self.send_header("X-Wii-Download-Span", "1") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 + if urlparse(self.path).path == "/healthz": + self._reply(b'{"status":"ok","service":"mail"}\n') + return + self.send_error(HTTPStatus.NOT_FOUND) + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path + length = int(self.headers.get("Content-Length", "0") or "0") + if length > 4 * 1024 * 1024: + self.send_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + return + body = self.rfile.read(length) + try: + if path.endswith("/account.cgi"): + form = parse_qs(body.decode("ascii"), keep_blank_values=True) + account = store.register(normalize_wii_id(form["mlid"][0])) + self._reply( + cgi_response(cd=100, passwd=account.password, mlchkid=account.mlchkid) + ) + return + if path.endswith("/check.cgi"): + form = parse_qs(body.decode("ascii"), keep_blank_values=True) + account = store.by_check_id(form["mlchkid"][0]) + if account is None: + self._reply(cgi_response(cd=210)) + return + self._reply( + store.check_response(account, form["chlng"][0]), + interval_headers=True, + ) + return + if path.endswith("/send.cgi"): + fields = _multipart_fields(self.headers.get("Content-Type", ""), body) + auth = AUTH.fullmatch(fields["mlid"].decode("ascii")) + if auth is None: + raise ValueError("invalid mail authentication field") + account = store.authenticate(auth.group(1), auth.group(2)) + if account is None: + self._reply(cgi_response(cd=210)) + return + results: dict[str, str | int] = {"cd": 100} + for name, payload in fields.items(): + if name == "mlid": + continue + entry_id = name[1:] if name.startswith("m") else name + try: + store.store_message(account, payload) + results[f"cd{entry_id}"] = 100 + except ValueError: + results[f"cd{entry_id}"] = 220 + self._reply(cgi_response(**results)) + return + if path.endswith("/receive.cgi"): + form = parse_qs(body.decode("ascii"), keep_blank_values=True) + account = store.authenticate( + normalize_wii_id(form["mlid"][0]), form["passwd"][0] + ) + if account is None: + self._reply(cgi_response(cd=250)) + return + messages = store.claim(account, int(form["maxsize"][0])) + boundary = f"jwc24/{secrets.token_hex(8)}" + mail_parts = [ + b"\r\n--" + + boundary.encode() + + b"\r\nContent-Type: text/plain\r\n\r\n" + + message + for message in messages + ] + mail_size = sum(map(len, mail_parts)) + result = cgi_response( + cd=100, + mailnum=len(messages), + mailsize=mail_size, + allnum=len(messages), + ) + response = ( + b"--" + + boundary.encode() + + b"\r\nContent-Type: text/plain\r\n\r\n" + + b"This part is ignored.\r\n\r\n" + + result + + b"".join(mail_parts) + + b"\r\n--" + + boundary.encode() + + b"--\r\n" + ) + self.send_response(HTTPStatus.OK) + self.send_header( + "Content-Type", f"multipart/mixed; boundary={boundary}" + ) + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + return + if path.endswith("/delete.cgi"): + form = parse_qs(body.decode("ascii"), keep_blank_values=True) + account = store.authenticate( + normalize_wii_id(form["mlid"][0]), form["passwd"][0] + ) + if account is None: + self._reply(cgi_response(cd=250)) + return + deleted = store.delete_claimed(account) + self._reply(cgi_response(cd=100, deletenum=deleted)) + return + except (KeyError, UnicodeError, ValueError): + self._reply(cgi_response(cd=220)) + return + self.send_error(HTTPStatus.NOT_FOUND) + + def log_message(self, fmt: str, *args: object) -> None: + print(f"{self.client_address[0]} {fmt % args}") + + return ThreadingHTTPServer((host, port), Handler) + + +def serve_mail(host: str, port: int, data_dir: Path) -> None: + server = create_mail_server(host, port, MailStore(data_dir)) + print(f"Serving shared WC24 mail on http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/jwc24/wc24_config.py b/jwc24/wc24_config.py index dd9be77..898afaa 100644 --- a/jwc24/wc24_config.py +++ b/jwc24/wc24_config.py @@ -13,6 +13,11 @@ STAGE_OFFSET = 0x14 ENABLE_BOOTING_OFFSET = 0x3F8 CHECKSUM_OFFSET = 0x3FC +EMAIL_OFFSET = 0x18 +MAIL_SECRET_OFFSET = 0x58 +MLCHKID_OFFSET = 0x78 +URLS_OFFSET = 0x9C +URL_LENGTH = 0x80 STAGE_INITIAL = 0 STAGE_GENERATED = 1 STAGE_REGISTERED = 2 @@ -44,6 +49,18 @@ def _put_u32(data: bytearray, offset: int, value: int) -> None: struct.pack_into(">I", data, offset, value) +def _read_c_string(data: bytes | bytearray, offset: int, length: int) -> str: + return bytes(data[offset : offset + length]).split(b"\0", 1)[0].decode("ascii") + + +def _put_c_string(data: bytearray, offset: int, length: int, value: str) -> None: + encoded = value.encode("ascii") + if len(encoded) >= length: + raise ValueError(f"value is too long for WC24 config field ({length - 1} bytes)") + data[offset : offset + length] = b"\0" * length + data[offset : offset + len(encoded)] = encoded + + def checksum(data: bytes | bytearray) -> int: return sum(_u32(data, offset) for offset in range(0, CHECKSUM_OFFSET, 4)) & 0xFFFFFFFF @@ -103,3 +120,57 @@ def bootstrap_local(path: Path, apply: bool) -> tuple[ConfigSummary, ConfigSumma shutil.copy2(path, backup) path.write_bytes(data) return before, after, backup + + +def mail_urls(data: bytes | bytearray) -> tuple[str, ...]: + validate(data) + return tuple( + _read_c_string(data, URLS_OFFSET + index * URL_LENGTH, URL_LENGTH) + for index in range(5) + ) + + +def mail_credentials_present(data: bytes | bytearray) -> tuple[bool, bool]: + validate(data) + return ( + bool(_read_c_string(data, MAIL_SECRET_OFFSET, 0x20)), + bool(_read_c_string(data, MLCHKID_OFFSET, 0x24)), + ) + + +def configure_mail( + path: Path, + base_url: str, + password: str, + mlchkid: str, + apply: bool, +) -> tuple[tuple[str, ...], tuple[str, ...], Path | None]: + original = read(path) + state = summarize(original) + if not state.checksum_valid or state.creation_stage != STAGE_REGISTERED: + raise ValueError("WC24 config must be valid and registered before mail provisioning") + if len(password) > 31 or not password: + raise ValueError("mail password must contain 1..31 ASCII characters") + if len(mlchkid) != 32: + raise ValueError("mail check ID must contain exactly 32 ASCII characters") + base = base_url.rstrip("/") + if not base.startswith(("http://", "https://")): + raise ValueError("mail base URL must be absolute HTTP(S)") + endpoints = ("account.cgi", "check.cgi", "receive.cgi", "delete.cgi", "send.cgi") + desired_urls = tuple(f"{base}/cgi-bin/{endpoint}" for endpoint in endpoints) + data = bytearray(original) + _put_c_string(data, EMAIL_OFFSET, 0x40, f"w{state.nwc24_id:016d}@wii.com") + _put_c_string(data, MAIL_SECRET_OFFSET, 0x20, password) + _put_c_string(data, MLCHKID_OFFSET, 0x24, mlchkid) + for index, url in enumerate(desired_urls): + _put_c_string(data, URLS_OFFSET + index * URL_LENGTH, URL_LENGTH, url) + _put_u32(data, CHECKSUM_OFFSET, checksum(data)) + before_urls = mail_urls(original) + after_urls = mail_urls(data) + if not apply or data == original: + return before_urls, after_urls, None + timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + backup = path.with_name(f"{path.name}.before-jwc24-mail-{timestamp}.bak") + shutil.copy2(path, backup) + path.write_bytes(data) + return before_urls, after_urls, backup diff --git a/tools/verify_mail_service.py b/tools/verify_mail_service.py new file mode 100644 index 0000000..9d922a2 --- /dev/null +++ b/tools/verify_mail_service.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import hashlib +import hmac +import tempfile +import threading +from pathlib import Path +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from jwc24.mail import MAIL_CHECK_KEY, NO_MAIL_FLAG, MailStore +from jwc24.mail_server import create_mail_server + + +SENDER = "8419982858718746" +RECIPIENT = "1234567890123456" + + +def post(url: str, body: bytes, content_type: str) -> bytes: + request = Request(url, body, headers={"Content-Type": content_type}, method="POST") + with urlopen(request, timeout=5) as response: + if response.status != 200: + raise ValueError(f"{url}: HTTP {response.status}") + return response.read() + + +def fields(payload: bytes) -> dict[str, str]: + return dict( + line.decode("ascii").split("=", 1) + for line in payload.splitlines() + if b"=" in line + ) + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="jwc24-mail-test-") as temporary: + store = MailStore(Path(temporary)) + server = create_mail_server("127.0.0.1", 0, store) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}/cgi-bin" + try: + sender = fields( + post( + f"{base}/account.cgi", + urlencode({"mlid": f"w{SENDER}", "hdid": "1", "rgncd": "JPN"}).encode(), + "application/x-www-form-urlencoded", + ) + ) + recipient = store.register(RECIPIENT) + challenge = "987654321" + checked = fields( + post( + f"{base}/check.cgi", + urlencode({"mlchkid": sender["mlchkid"], "chlng": challenge}).encode(), + "application/x-www-form-urlencoded", + ) + ) + mime_message = ( + f"From: w{SENDER}@wii.com\r\n" + f"To: w{RECIPIENT}@wii.com\r\n" + "Subject: JWC24 self-test\r\n" + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + "Reusable Wii Mail test.\r\n" + ).encode() + message = ( + f"MAIL FROM: w{SENDER}@wii.com\r\n" + f"RCPT TO: w{RECIPIENT}@wii.com\r\n" + "DATA\r\n\r\n" + ).encode() + mime_message + boundary = "jwc24-test-boundary" + auth = f"mlid=w{SENDER}\r\npasswd={sender['passwd']}".encode() + multipart = bytearray() + for name, value in (("mlid", auth), ("m7", message)): + multipart.extend(f"--{boundary}\r\n".encode()) + multipart.extend( + f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode() + ) + multipart.extend(value) + multipart.extend(b"\r\n") + multipart.extend(f"--{boundary}--\r\n".encode()) + sent = fields( + post( + f"{base}/send.cgi", + bytes(multipart), + f"multipart/form-data; boundary={boundary}", + ) + ) + pending = store.pending(recipient.wii_id) + if sender.get("cd") != "100" or checked.get("cd") != "100": + raise ValueError("account/check CGI did not return success") + expected_hmac = hmac.new( + MAIL_CHECK_KEY, + f"{challenge}\nw{SENDER}\n{NO_MAIL_FLAG}\n1".encode(), + hashlib.sha1, + ).hexdigest() + if checked.get("res") != expected_hmac: + raise ValueError("mail-check HMAC differs from Dolphin's contract") + if sent.get("cd") != "100" or sent.get("cd7") != "100": + raise ValueError("send CGI did not accept message") + if len(pending) != 1 or pending[0].read_bytes() != mime_message: + raise ValueError("recipient mailbox did not preserve the MIME message") + refreshed = store.register(RECIPIENT) + if refreshed.mail_flag == NO_MAIL_FLAG: + raise ValueError("recipient mail flag did not advance") + received = post( + f"{base}/receive.cgi", + urlencode( + { + "mlid": f"w{RECIPIENT}", + "passwd": recipient.password, + "maxsize": "1048576", + } + ).encode(), + "application/x-www-form-urlencoded", + ) + if mime_message not in received: + raise ValueError("receive CGI omitted the queued MIME message") + deleted = fields( + post( + f"{base}/delete.cgi", + urlencode( + { + "mlid": f"w{RECIPIENT}", + "passwd": recipient.password, + "delnum": "1", + } + ).encode(), + "application/x-www-form-urlencoded", + ) + ) + if deleted.get("cd") != "100" or store.pending(RECIPIENT): + raise ValueError("delete CGI did not clear delivered mail") + print( + "valid shared WC24 mail service: " + "account=100 check=100 hmac=ok send=100 receive=1 delete=1" + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())