Skip to main content

tuwunel_service/threepid/
binding.rs

1//! Persistent email-to-user bindings.
2//!
3//! Forward rows retain binding metadata for each user, while reverse rows
4//! resolve one canonical email to its owner. Streamed listing and point lookup
5//! expose the two index directions to account and invitation flows.
6
7use futures::{Stream, StreamExt};
8use ruma::{
9	MilliSecondsSinceUnixEpoch, OwnedUserId, UserId,
10	thirdparty::{Medium, ThirdPartyIdentifier, ThirdPartyIdentifierInit},
11};
12use tuwunel_core::{Result, implement, result::NotFound, utils::stream::TryIgnore};
13use tuwunel_database::{Cbor, Deserialized, Ignore, Interfix};
14
15use super::Binding;
16
17/// Persists a canonical email binding in both index directions.
18///
19/// The forward row stores the medium and timestamps, while the reverse row
20/// stores the owning user. These are separate map writes, so callers must not
21/// treat the two rows as one atomic snapshot.
22#[implement(super::Service)]
23#[tracing::instrument(
24	level = "debug",
25	skip(self),
26	fields(
27		%user_id,
28	),
29)]
30pub async fn put_binding(
31	&self,
32	user_id: &UserId,
33	email_canon: &str,
34	medium: Medium,
35	validated_at: MilliSecondsSinceUnixEpoch,
36	added_at: MilliSecondsSinceUnixEpoch,
37) {
38	let binding = Binding { medium, validated_at, added_at };
39
40	self.db
41		.userid_email
42		.put((user_id, email_canon), Cbor(binding));
43
44	self.db.email_userid.insert(email_canon, user_id);
45}
46
47/// Streams all third-party identifiers bound to `user_id`.
48///
49/// Entries are decoded lazily from the user's forward-index prefix. Storage or
50/// decoding failures are skipped, and each yielded identifier owns its data.
51#[implement(super::Service)]
52#[tracing::instrument(
53	level = "debug",
54	skip(self),
55	fields(
56		%user_id,
57	),
58)]
59pub fn get_bindings<'a>(
60	&'a self,
61	user_id: &'a UserId,
62) -> impl Stream<Item = ThirdPartyIdentifier> + Send + 'a {
63	type KeyVal = ((Ignore, String), Cbor<Binding>);
64
65	self.db
66		.userid_email
67		.stream_prefix(&(user_id, Interfix))
68		.ignore_err()
69		.map(|((_, address), Cbor(binding)): KeyVal| {
70			ThirdPartyIdentifierInit {
71				address,
72				medium: binding.medium,
73				validated_at: binding.validated_at,
74				added_at: binding.added_at,
75			}
76			.into()
77		})
78}
79
80/// Removes a canonical email binding from both index directions.
81///
82/// The forward row is deleted even when absent. The reverse row is removed
83/// only when a successful lookup still names this user, so a read failure or a
84/// different owner leaves that row untouched.
85#[implement(super::Service)]
86#[tracing::instrument(
87	level = "debug",
88	skip(self),
89	fields(
90		%user_id,
91	),
92)]
93pub async fn del_binding(&self, user_id: &UserId, email_canon: &str) {
94	self.db.userid_email.del((user_id, email_canon));
95
96	if self
97		.user_id_for_email(email_canon)
98		.await
99		.ok()
100		.flatten()
101		.is_some_and(|bound| bound == user_id)
102	{
103		self.db.email_userid.remove(email_canon);
104	}
105}
106
107/// Whether a canonical email address is bound to an account other than this
108/// one.
109///
110/// No binding or a binding to `user_id` returns `false`. Storage and decoding
111/// failures from the reverse lookup are propagated.
112#[implement(super::Service)]
113#[tracing::instrument(
114	level = "debug",
115	skip(self),
116	fields(
117		%user_id,
118	),
119)]
120pub async fn bound_elsewhere(&self, user_id: &UserId, email_canon: &str) -> Result<bool> {
121	self.user_id_for_email(email_canon)
122		.await
123		.map(|bound| bound.is_some_and(|bound| bound != user_id))
124}
125
126/// Returns the user bound to a canonical email address.
127///
128/// An absent reverse row returns `None`. Storage and user-ID decoding failures
129/// are propagated.
130#[implement(super::Service)]
131#[tracing::instrument(level = "debug", skip(self))]
132pub async fn user_id_for_email(&self, email_canon: &str) -> Result<Option<OwnedUserId>> {
133	self.db
134		.email_userid
135		.get(email_canon)
136		.await
137		.optional()?
138		.map(|handle| handle.deserialized())
139		.transpose()
140}
141
142/// Tests whether a canonical email address has a readable reverse row.
143///
144/// Any successful raw lookup returns `true` without decoding the stored user.
145/// Absence and all storage failures are both collapsed to `false`.
146#[implement(super::Service)]
147#[tracing::instrument(level = "debug", skip(self))]
148pub async fn address_in_use(&self, email_canon: &str) -> bool {
149	self.db
150		.email_userid
151		.get(email_canon)
152		.await
153		.is_ok()
154}