Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
43 changes: 42 additions & 1 deletion jwc24/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The message conflates a no-op --apply run with a dry run, which can be misleading.

In mail-config, configure_mail returns backup=None both when --apply is omitted and when --apply is used but no changes are needed (data == original). Since the message is based solely on backup is None, users who did pass --apply but had no changes see “dry run only; pass --apply,” which is incorrect. It would be clearer to branch on apply and whether changes were made, e.g. distinct messages for: apply=False, apply=True && data==original, and apply=True && data!=original.

Suggested implementation:

            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}")

            # Distinguish between dry-run, no-op apply, and applied changes
            changes_made = list(before) != list(after)

            if not args.apply:
                if changes_made:
                    print("dry run only; pass --apply to apply these changes")
                else:
                    print("dry run; no changes needed")
            elif not changes_made:
                print("no changes needed; configuration already up to date")
            else:
                print(f"applied; backup: {backup}")
            return 0
        if args.command == "account":
            if args.apply and not args.bootstrap_local:
                raise ValueError("--apply requires --bootstrap-local")
                print(f"checksum: {after.calculated_checksum:08x} (valid={after.checksum_valid})")
                # Reflect whether this was an apply or a dry-run, and if any changes were needed
                if args.apply:
                    print(f"applied; backup: {backup}" if backup else "applied; no changes needed")
                else:
                    print("dry run; no changes needed")
            else:
                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}")
  • In the second block, I don’t see before/after being set in the snippet; if this branch also has access to them, you may want to mirror the changes_made = list(before) != list(after) logic from the first block for consistency and to avoid inferring “no changes” solely from backup is None.
  • If configure_mail (or the code in the account branch) can ever produce a non-None backup for a dry run, you should adjust the second branch’s messages to account for that (e.g., by computing changes_made there as well).

return 0
if args.command == "account":
if args.apply and not args.bootstrap_local:
raise ValueError("--apply requires --bootstrap-local")
Expand All @@ -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}")
Expand All @@ -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)
Expand Down
218 changes: 218 additions & 0 deletions jwc24/mail.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +182 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve repeated deliveries with unique queue entries

When the same serialized MIME payload is sent to a recipient more than once before the first copy is consumed, both deliveries produce the same digest-derived path, so the second is silently skipped while send.cgi still reports cd=100. Identical messages are valid—especially for automated/template mail—and a queue should preserve each accepted delivery rather than deduplicating it by content hash; use a unique per-delivery filename.

Useful? React with 👍 / 👎.

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"))
Comment on lines +202 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep claimed mail available until deletion is acknowledged

If the connection closes while receive.cgi is writing its multipart response, this rename has already removed the message from the *.eml set used by both pending() and later claim() calls. Since the client never gets a chance to acknowledge the download with delete.cgi, subsequent checks report no mail and retries cannot retrieve the stranded *.sent message, causing permanent delivery loss; claims must remain retryable or be rolled back when the response is not completed.

Useful? React with 👍 / 👎.

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)
Loading
Loading