@@ -144,20 +144,37 @@ public async Task<OneOf<Success<User>, AccountWithEmailOrUsernameExists>> Create
144144
145145 await using var tx = await _db . Database . BeginTransactionAsync ( ) ;
146146
147+ string ? activationToken = null ;
148+ Guid userId ;
149+
147150 try
148151 {
149152 var user = new User
150153 {
151154 Id = Guid . CreateVersion7 ( ) ,
152155 Name = username ,
153156 Email = email ,
154- PasswordHash = null , // OAuth-only account
155- ActivatedAt = DateTime . UtcNow // no activation flow
157+ PasswordHash = null ,
158+ ActivatedAt = isEmailTrusted ? DateTime . UtcNow : null
156159 } ;
157160
158161 _db . Users . Add ( user ) ;
159162 await _db . SaveChangesAsync ( ) ;
160163
164+ // If email isn't trusted, create an activation request (email verification)
165+ if ( ! isEmailTrusted )
166+ {
167+ activationToken = CryptoUtils . RandomString ( AuthConstants . GeneratedTokenLength ) ;
168+
169+ user . UserActivationRequest = new UserActivationRequest
170+ {
171+ UserId = user . Id ,
172+ TokenHash = HashingUtils . HashToken ( activationToken )
173+ } ;
174+
175+ await _db . SaveChangesAsync ( ) ;
176+ }
177+
161178 // Link external identity
162179 _db . UserOAuthConnections . Add ( new UserOAuthConnection
163180 {
@@ -167,27 +184,37 @@ public async Task<OneOf<Success<User>, AccountWithEmailOrUsernameExists>> Create
167184 DisplayName = providerAccountName
168185 } ) ;
169186
187+ // Tidy-up if clock skew makes CreatedAt > ActivatedAt (trusted case only)
188+ if ( user . ActivatedAt is not null && user . CreatedAt > user . ActivatedAt )
189+ {
190+ user . ActivatedAt = user . CreatedAt ;
191+ }
192+
170193 await _db . SaveChangesAsync ( ) ;
171194
195+ userId = user . Id ;
196+
172197 await tx . CommitAsync ( ) ;
173198
174- // Ensure ActivatedAt <= CreatedAt (optional monotonic tidy-up)
175- if ( user . CreatedAt > user . ActivatedAt )
199+ // Send verification email only after successful commit
200+ if ( ! isEmailTrusted && activationToken is not null )
176201 {
177- user . ActivatedAt = user . CreatedAt ;
178- await _db . SaveChangesAsync ( ) ;
202+ await _emailService . VerifyEmail (
203+ new Contact ( email , username ) ,
204+ new Uri ( _frontendConfig . BaseUrl , $ "/#/account/activate/{ userId } /{ activationToken } ")
205+ ) ;
179206 }
180207
181208 return new Success < User > ( user ) ;
182209 }
183210 catch ( DbUpdateException ex ) when ( ex . InnerException is PostgresException { SqlState : "23505" } )
184211 {
185- // Unique constraint hit: either username/email already exists, or (provider, externalId) is already linked.
186212 await tx . RollbackAsync ( ) ;
187213 return new AccountWithEmailOrUsernameExists ( ) ;
188214 }
189215 }
190216
217+
191218 public async Task < bool > TryActivateAccountAsync ( string secret , CancellationToken cancellationToken = default )
192219 {
193220 var hash = HashingUtils . HashToken ( secret ) ;
0 commit comments