Skip to content

Commit aae182b

Browse files
atergaclaude
andcommitted
feat(be): implement SMTP Gateway Protocol for email reception PoC
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>
1 parent 771a003 commit aae182b

File tree

8 files changed

+437
-0
lines changed

8 files changed

+437
-0
lines changed

src/internet_identity/internet_identity.did

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,44 @@ type ListAvailableAttributesError = variant {
10341034
AuthorizationError : principal;
10351035
};
10361036

1037+
// SMTP Gateway Protocol types
1038+
// ============================
1039+
type SmtpHeader = record {
1040+
name : text;
1041+
value : text;
1042+
};
1043+
1044+
type SmtpMessage = record {
1045+
headers : vec SmtpHeader;
1046+
body : blob;
1047+
};
1048+
1049+
type SmtpAddress = record {
1050+
user : text;
1051+
domain : text;
1052+
};
1053+
1054+
type SmtpEnvelope = record {
1055+
from : SmtpAddress;
1056+
to : SmtpAddress;
1057+
};
1058+
1059+
type SmtpRequest = record {
1060+
message : opt SmtpMessage;
1061+
envelope : opt SmtpEnvelope;
1062+
gateway_flags : opt vec text;
1063+
};
1064+
1065+
type SmtpRequestError = record {
1066+
code : nat64;
1067+
message : text;
1068+
};
1069+
1070+
type SmtpResponse = variant {
1071+
Ok : record {};
1072+
Err : SmtpRequestError;
1073+
};
1074+
10371075
service : (opt InternetIdentityInit) -> {
10381076
// Legacy identity management API
10391077
// ==============================
@@ -1239,4 +1277,9 @@ service : (opt InternetIdentityInit) -> {
12391277

12401278
// Looks up identity number when called with a recovery phrase
12411279
lookup_caller_identity_by_recovery_phrase : () -> (opt IdentityNumber);
1280+
1281+
// SMTP Gateway Protocol
1282+
// =====================
1283+
smtp_request : (SmtpRequest) -> (SmtpResponse);
1284+
smtp_request_validate : (SmtpRequest) -> (SmtpResponse) query;
12421285
};

src/internet_identity/src/main.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use internet_identity_interface::internet_identity::types::vc_mvp::{
3333
GetIdAliasError, GetIdAliasRequest, IdAliasCredentials, PrepareIdAliasError,
3434
PrepareIdAliasRequest, PreparedIdAlias,
3535
};
36+
use internet_identity_interface::internet_identity::types::smtp::{SmtpRequest, SmtpResponse};
3637
use internet_identity_interface::internet_identity::types::*;
3738
use serde_bytes::ByteBuf;
3839
use std::collections::HashMap;
@@ -53,6 +54,7 @@ mod http;
5354
mod ii_domain;
5455

5556
mod openid;
57+
mod smtp;
5658
mod state;
5759
mod stats;
5860
mod storage;
@@ -1441,6 +1443,21 @@ mod attribute_sharing_old_vc {
14411443
}
14421444
}
14431445

1446+
mod smtp_gateway {
1447+
use super::*;
1448+
use internet_identity_interface::internet_identity::types::smtp::{SmtpRequest, SmtpResponse};
1449+
1450+
#[update]
1451+
fn smtp_request(request: SmtpRequest) -> SmtpResponse {
1452+
smtp::handle_smtp_request(request)
1453+
}
1454+
1455+
#[query]
1456+
fn smtp_request_validate(request: SmtpRequest) -> SmtpResponse {
1457+
smtp::handle_smtp_request_validate(request)
1458+
}
1459+
}
1460+
14441461
fn main() {}
14451462

14461463
// Order dependent: do not move above any exposed canister method!

src/internet_identity/src/smtp.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
use crate::state;
2+
use crate::storage::storable::smtp::StorableEmail;
3+
use internet_identity_interface::internet_identity::types::smtp::{
4+
validate_envelope_only, SmtpRequest, SmtpResponse, ValidatedSmtpRequest,
5+
};
6+
7+
pub fn handle_smtp_request(request: SmtpRequest) -> SmtpResponse {
8+
let validated: ValidatedSmtpRequest = match request.try_into() {
9+
Ok(v) => v,
10+
Err(response) => return response,
11+
};
12+
13+
let email = StorableEmail {
14+
sender: validated.sender,
15+
recipient: validated.recipient.clone(),
16+
subject: validated.subject,
17+
body: validated.body,
18+
};
19+
20+
state::storage_borrow_mut(|storage| {
21+
storage.store_email(validated.recipient, email);
22+
});
23+
24+
SmtpResponse::Ok {}
25+
}
26+
27+
pub fn handle_smtp_request_validate(request: SmtpRequest) -> SmtpResponse {
28+
// If a full message is present, run full validation
29+
if request.message.is_some() {
30+
return match ValidatedSmtpRequest::try_from(request) {
31+
Ok(_) => SmtpResponse::Ok {},
32+
Err(response) => response,
33+
};
34+
}
35+
36+
// Otherwise validate just the envelope (and whatever else is present)
37+
match validate_envelope_only(&request) {
38+
Ok(()) => SmtpResponse::Ok {},
39+
Err(response) => response,
40+
}
41+
}

src/internet_identity/src/storage.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ use storable::discrepancy_counter::{DiscrepancyType, StorableDiscrepancyCounter}
130130
use storable::fixed_anchor::StorableFixedAnchor;
131131
use storable::openid_credential::StorableOpenIdCredential;
132132
use storable::openid_credential_key::StorableOpenIdCredentialKey;
133+
use storable::smtp::{StorableEmail, StorableEmailAddress, StorableEmailList};
133134
use storable::storable_persistent_state::StorablePersistentState;
134135

135136
pub mod anchor;
@@ -178,6 +179,7 @@ const LOOKUP_APPLICATION_WITH_ORIGIN_MEMORY_INDEX: u8 = 19u8;
178179
const STABLE_ANCHOR_APPLICATION_CONFIG_MEMORY_INDEX: u8 = 20u8;
179180
const LOOKUP_ANCHOR_WITH_RECOVERY_PHRASE_PRINCIPAL_MEMORY_INDEX: u8 = 21u8;
180181
const LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_INDEX: u8 = 22u8;
182+
const SMTP_POSTBOX_MEMORY_INDEX: u8 = 23u8;
181183

182184
const ANCHOR_MEMORY_ID: MemoryId = MemoryId::new(ANCHOR_MEMORY_INDEX);
183185
const ARCHIVE_BUFFER_MEMORY_ID: MemoryId = MemoryId::new(ARCHIVE_BUFFER_MEMORY_INDEX);
@@ -215,6 +217,8 @@ const LOOKUP_ANCHOR_WITH_RECOVERY_PHRASE_PRINCIPAL_MEMORY_ID: MemoryId =
215217
const LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_ID: MemoryId =
216218
MemoryId::new(LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_INDEX);
217219

220+
const SMTP_POSTBOX_MEMORY_ID: MemoryId = MemoryId::new(SMTP_POSTBOX_MEMORY_INDEX);
221+
218222
// The bucket size 128 is relatively low, to avoid wasting memory when using
219223
// multiple virtual memories for smaller amounts of data.
220224
// This value results in 256 GB of total managed memory, which should be enough
@@ -327,6 +331,9 @@ pub struct Storage<M: Memory> {
327331
lookup_anchor_with_passkey_pubkey_hash_memory_wrapper: MemoryWrapper<ManagedMemory<M>>,
328332
pub(crate) lookup_anchor_with_passkey_pubkey_hash_memory:
329333
StableBTreeMap<Principal, StorableAnchorNumber, ManagedMemory<M>>,
334+
335+
smtp_postbox_memory_wrapper: MemoryWrapper<ManagedMemory<M>>,
336+
smtp_postbox: StableBTreeMap<StorableEmailAddress, StorableEmailList, ManagedMemory<M>>,
330337
}
331338

332339
#[repr(C, packed)]
@@ -410,6 +417,7 @@ impl<M: Memory + Clone> Storage<M> {
410417
memory_manager.get(LOOKUP_ANCHOR_WITH_RECOVERY_PHRASE_PRINCIPAL_MEMORY_ID);
411418
let lookup_anchor_with_passkey_pubkey_hash_memory =
412419
memory_manager.get(LOOKUP_ANCHOR_WITH_PASSKEY_PUBKEY_HASH_MEMORY_ID);
420+
let smtp_postbox_memory = memory_manager.get(SMTP_POSTBOX_MEMORY_ID);
413421

414422
let registration_rates = RegistrationRates::new(
415423
MinHeap::init(registration_ref_rate_memory.clone())
@@ -510,6 +518,8 @@ impl<M: Memory + Clone> Storage<M> {
510518
lookup_anchor_with_passkey_pubkey_hash_memory: StableBTreeMap::init(
511519
lookup_anchor_with_passkey_pubkey_hash_memory,
512520
),
521+
smtp_postbox_memory_wrapper: MemoryWrapper::new(smtp_postbox_memory.clone()),
522+
smtp_postbox: StableBTreeMap::init(smtp_postbox_memory),
513523
}
514524
}
515525

@@ -1829,6 +1839,28 @@ impl<M: Memory + Clone> Storage<M> {
18291839
self.header.version
18301840
}
18311841

1842+
pub fn store_email(&mut self, recipient: String, email: StorableEmail) {
1843+
use internet_identity_interface::internet_identity::types::smtp::MAX_EMAILS_PER_USER;
1844+
1845+
let key = StorableEmailAddress(recipient);
1846+
let mut list = self
1847+
.smtp_postbox
1848+
.get(&key)
1849+
.unwrap_or(StorableEmailList {
1850+
emails: Vec::new(),
1851+
});
1852+
1853+
list.emails.push(email);
1854+
1855+
// Keep only the most recent emails
1856+
if list.emails.len() > MAX_EMAILS_PER_USER {
1857+
let start = list.emails.len() - MAX_EMAILS_PER_USER;
1858+
list.emails = list.emails.split_off(start);
1859+
}
1860+
1861+
self.smtp_postbox.insert(key, list);
1862+
}
1863+
18321864
pub fn memory_sizes(&self) -> HashMap<String, u64> {
18331865
HashMap::from_iter(vec![
18341866
("header".to_string(), self.header_memory.size()),
@@ -1905,6 +1937,10 @@ impl<M: Memory + Clone> Storage<M> {
19051937
self.lookup_anchor_with_passkey_pubkey_hash_memory_wrapper
19061938
.size(),
19071939
),
1940+
(
1941+
"smtp_postbox".to_string(),
1942+
self.smtp_postbox_memory_wrapper.size(),
1943+
),
19081944
])
19091945
}
19101946
}

src/internet_identity/src/storage/storable.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ pub mod openid_credential;
1717
pub mod openid_credential_key;
1818
pub mod passkey_credential;
1919
pub mod recovery_key;
20+
pub mod smtp;
2021
pub mod special_device_migration;
2122
pub mod storable_persistent_state;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use candid::{CandidType, Deserialize};
2+
use ic_stable_structures::storable::Bound;
3+
use ic_stable_structures::Storable;
4+
use internet_identity_interface::internet_identity::types::smtp::{
5+
MAX_BODY_BYTES, MAX_EMAILS_PER_USER, MAX_EMAIL_DOMAIN_BYTES, MAX_EMAIL_USER_BYTES,
6+
MAX_SUBJECT_BYTES,
7+
};
8+
use std::borrow::Cow;
9+
10+
/// Max serialized size of a single email address key.
11+
/// user (64) + "@" (1) + domain (255) + Candid overhead (~20 bytes).
12+
const STORABLE_EMAIL_ADDRESS_MAX_SIZE: u32 = (MAX_EMAIL_USER_BYTES + 1 + MAX_EMAIL_DOMAIN_BYTES + 20) as u32;
13+
14+
/// Max serialized size of a single email.
15+
/// sender (320) + recipient (320) + subject (256) + body (5_000) + Candid overhead (~100 bytes).
16+
const STORABLE_EMAIL_MAX_SIZE: u32 =
17+
(MAX_EMAIL_USER_BYTES + 1 + MAX_EMAIL_DOMAIN_BYTES) as u32 * 2
18+
+ MAX_SUBJECT_BYTES as u32
19+
+ MAX_BODY_BYTES as u32
20+
+ 100;
21+
22+
/// Max serialized size of the email list value.
23+
const STORABLE_EMAIL_LIST_MAX_SIZE: u32 = STORABLE_EMAIL_MAX_SIZE * MAX_EMAILS_PER_USER as u32 + 100;
24+
25+
#[derive(Clone, Debug, CandidType, Deserialize, Ord, PartialOrd, Eq, PartialEq)]
26+
pub struct StorableEmailAddress(pub String);
27+
28+
impl Storable for StorableEmailAddress {
29+
fn to_bytes(&self) -> Cow<'_, [u8]> {
30+
Cow::Owned(candid::encode_one(self).expect("failed to encode StorableEmailAddress"))
31+
}
32+
33+
fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
34+
candid::decode_one(&bytes).expect("failed to decode StorableEmailAddress")
35+
}
36+
37+
const BOUND: Bound = Bound::Bounded {
38+
max_size: STORABLE_EMAIL_ADDRESS_MAX_SIZE,
39+
is_fixed_size: false,
40+
};
41+
}
42+
43+
#[derive(Clone, Debug, CandidType, Deserialize)]
44+
pub struct StorableEmail {
45+
pub sender: String,
46+
pub recipient: String,
47+
pub subject: String,
48+
pub body: String,
49+
}
50+
51+
#[derive(Clone, Debug, CandidType, Deserialize)]
52+
pub struct StorableEmailList {
53+
pub emails: Vec<StorableEmail>,
54+
}
55+
56+
impl Storable for StorableEmailList {
57+
fn to_bytes(&self) -> Cow<'_, [u8]> {
58+
Cow::Owned(candid::encode_one(self).expect("failed to encode StorableEmailList"))
59+
}
60+
61+
fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
62+
candid::decode_one(&bytes).expect("failed to decode StorableEmailList")
63+
}
64+
65+
const BOUND: Bound = Bound::Bounded {
66+
max_size: STORABLE_EMAIL_LIST_MAX_SIZE,
67+
is_fixed_size: false,
68+
};
69+
}

src/internet_identity_interface/src/internet_identity/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod api_v2;
2323
pub mod attributes;
2424
pub mod icrc3;
2525
pub mod openid;
26+
pub mod smtp;
2627
pub mod vc_mvp;
2728

2829
// re-export v2 types without the ::v2 prefix, so that this crate can be restructured once v1 is removed

0 commit comments

Comments
 (0)