[DO NOT MERGE] feat: email reception with Postbox UI for identity owners#3760
[DO NOT MERGE] feat: email reception with Postbox UI for identity owners#3760
Conversation
Add `smtp_request` and `smtp_request_validate` canister endpoints following the SMTP Gateway Protocol spec. Emails to whitelisted users (arshavir, thomas, shiling, igor, ruediger, bjoern) at @beta.id.ai are accepted and stored in a new `smtp_postbox` stable BTreeMap, keeping the 10 most recent emails per recipient. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a proof-of-concept SMTP Gateway Protocol surface to the Internet Identity canister, including Candid types, request validation, and stable-memory storage of received emails.
Changes:
- Introduces new SMTP Candid types plus
smtp_request(update) andsmtp_request_validate(query) canister methods. - Implements SMTP request validation (domain/user whitelist, size/header bounds, SMTP-style error responses).
- Adds stable-memory “postbox” storage keyed by recipient address with per-user pruning.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/internet_identity/src/storage/storable/smtp.rs |
Adds stable-structures Storable* wrappers for SMTP postbox keys/values with bounded sizes. |
src/internet_identity/src/storage/storable.rs |
Exposes the new storable::smtp module. |
src/internet_identity/src/storage.rs |
Allocates a new stable memory region and adds store_email() with max-10 retention. |
src/internet_identity/src/smtp.rs |
Adds canister-side handlers for storing vs. validating SMTP requests. |
src/internet_identity/src/main.rs |
Exposes new canister endpoints under mod smtp_gateway. |
src/internet_identity/internet_identity.did |
Adds SMTP types and service methods to the public Candid interface. |
src/internet_identity_interface/src/internet_identity/types/smtp.rs |
Defines interface types plus validation and TryFrom to a validated internal representation. |
src/internet_identity_interface/src/internet_identity/types.rs |
Re-exports the new smtp module. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .find(|h| h.name.eq_ignore_ascii_case("subject")) | ||
| .map(|h| { | ||
| let mut s = h.value.clone(); | ||
| s.truncate(MAX_SUBJECT_BYTES); |
There was a problem hiding this comment.
String::truncate(MAX_SUBJECT_BYTES) can panic if the subject contains multi-byte UTF-8 and byte 256 is not a char boundary, which would trap the canister on a crafted email. Truncate safely (e.g., clamp to the nearest prior is_char_boundary, or truncate by chars()), or validate/reject non-ASCII subjects before truncating by bytes.
| s.truncate(MAX_SUBJECT_BYTES); | |
| if s.len() > MAX_SUBJECT_BYTES { | |
| let mut end = MAX_SUBJECT_BYTES; | |
| while end > 0 && !s.is_char_boundary(end) { | |
| end -= 1; | |
| } | |
| s.truncate(end); | |
| } |
| .message | ||
| .as_ref() | ||
| .ok_or_else(|| smtp_err(SMTP_ERR_SYNTAX_ERROR, "Missing message"))?; | ||
|
|
||
| validate_message(message)?; | ||
|
|
||
| let body = String::from_utf8_lossy(&message.body).into_owned(); | ||
|
|
||
| Ok(ValidatedSmtpRequest { | ||
| sender: format_address(&envelope.from), | ||
| recipient: format_address(&envelope.to), | ||
| subject: extract_subject(&message.headers), | ||
| body, | ||
| }) |
There was a problem hiding this comment.
String::from_utf8_lossy(&message.body) can expand invalid UTF-8 into U+FFFD (3 bytes), meaning a body that passed the MAX_BODY_BYTES check can become larger than the storage bounds (and/or stored content differs from what was sent), potentially causing a trap when inserting into stable storage. Consider storing the body as bytes (Vec<u8>/ByteBuf) in ValidatedSmtpRequest/StorableEmail, or enforce that the body is valid UTF-8 and re-check/truncate the resulting string to MAX_BODY_BYTES (byte length) before storing.
| fn validate_envelope(envelope: &SmtpEnvelope) -> Result<(), SmtpResponse> { | ||
| validate_address_bounds(&envelope.from, "Sender")?; | ||
| validate_address_bounds(&envelope.to, "Recipient")?; | ||
|
|
||
| if !envelope.to.domain.eq_ignore_ascii_case(ACCEPTED_DOMAIN) { | ||
| return Err(smtp_err( | ||
| SMTP_ERR_MAILBOX_UNAVAILABLE, | ||
| format!("Relay not permitted: domain must be {ACCEPTED_DOMAIN}"), | ||
| )); | ||
| } | ||
|
|
||
| let user_lower = envelope.to.user.to_lowercase(); | ||
| if !ACCEPTED_USERS.contains(&user_lower.as_str()) { | ||
| return Err(smtp_err( | ||
| SMTP_ERR_MAILBOX_UNAVAILABLE, | ||
| "Mailbox unavailable", | ||
| )); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
This new SMTP validation logic is security/availability sensitive (must not trap, must enforce bounds consistently). There are existing Rust unit tests in this crate (e.g., types/attributes.rs), but smtp.rs introduces many branches without tests; please add unit tests covering: missing envelope/message, non-accepted domain/user, header count/name/value limits, body size limit, and non-UTF8 body/Unicode subject cases.
src/internet_identity/src/main.rs
Outdated
| OpenIdCredentialAddError, OpenIdCredentialRemoveError, OpenIdDelegationError, | ||
| OpenIdPrepareDelegationResponse, | ||
| }; | ||
| use internet_identity_interface::internet_identity::types::smtp::{SmtpRequest, SmtpResponse}; |
There was a problem hiding this comment.
The top-level use ...types::smtp::{SmtpRequest, SmtpResponse}; appears unused in this module (the SMTP types are imported again inside mod smtp_gateway). With -D warnings in CI, this likely fails compilation/clippy. Remove this import, or remove the inner import and reference the types via super::SmtpRequest/super::SmtpResponse consistently.
| use internet_identity_interface::internet_identity::types::smtp::{SmtpRequest, SmtpResponse}; |
| } | ||
|
|
||
| fn format_address(addr: &SmtpAddress) -> String { | ||
| format!("{}@{}", addr.user, addr.domain) |
There was a problem hiding this comment.
format_address preserves the original envelope.to.user/domain casing, but validation is case-insensitive (eq_ignore_ascii_case and to_lowercase for whitelisting). That means the same logical mailbox can be stored under multiple stable-map keys by varying case, bypassing the per-user pruning and enabling unbounded key growth. Canonicalize the persisted mailbox key (e.g., lowercase domain and user, since the whitelist already treats user case-insensitively) before storing.
| format!("{}@{}", addr.user, addr.domain) | |
| format!( | |
| "{}@{}", | |
| addr.user.to_ascii_lowercase(), | |
| addr.domain.to_ascii_lowercase() | |
| ) |
- Add `get_postbox` query endpoint returning emails for an anchor number - Add `PostboxEmail` Candid type and regenerate frontend IDL/types - Change SMTP recipient format from hardcoded users to `anchor_number@id.ai` - Add Postbox nav tab (visible only when emails exist) with MailIcon - Create Postbox page with two-column layout: email list + email content - Load postbox data in authenticated layout alongside identity info Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove the hardcoded ACCEPTED_DOMAIN ("id.ai") restriction so emails
are accepted regardless of domain. Storage is now keyed solely by the
parsed anchor number from the recipient user part.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show the recipient address alongside From, since emails can arrive via domain aliases that differ from the anchor number. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keep main's locale translations while preserving postbox entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Real-world emails commonly exceed 10 headers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
<anchor_number>@id.aiand view them in a new Postbox tab in the management dashboard.Changes
Backend
smtp_request(update) andsmtp_request_validate(query) canister endpoints implementing the SMTP Gateway Protocol.get_postbox(query) endpoint returning stored emails for an anchor number (newest first).smtp_postboxstable BTreeMap (memory ID 23).<valid_u64>@id.ai, body ≤ 5 KB, ≤ 10 headers.Frontend
identity_infoin the authenticated layout..didfile.🤖 Generated with Claude Code