Skip to content

Commit 290ef5d

Browse files
committed
Add sendEmail: boolean to mailbox challenges
1 parent 2fa99be commit 290ef5d

31 files changed

Lines changed: 237 additions & 232 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.9.0] - 2026-06-16
11+
12+
### Added
13+
14+
- `@passlock/server` mailbox challenge creation now accepts `sendEmail: true` to request Passlock-managed delivery for one-time-code emails. Omitting `sendEmail`, or passing `sendEmail: false`, preserves the existing generate-only behavior and keeps returning the generated `code` and rendered `message` for custom delivery.
15+
-
16+
- `@passlock/server` and the `POST /v2/:tenancyId/challenges` REST API now accept an optional recipient `name` when creating mailbox challenges. The name is used for email delivery only and does not affect challenge scope, lookup, invalidation, or verification.
17+
1018
## [2.8.0] - 2026-06-15
1119

1220
### Added

examples/sveltekit/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@passlock/sveltekit-example",
3-
"version": "2.8.0",
3+
"version": "2.9.0",
44
"private": true,
55
"description": "SvelteKit app making full use of passkeys along with email based one time login codes",
66
"type": "module",

examples/sveltekit/src/lib/server/cookies.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { SESSION_MAX_INACTIVE_MS } from './session';
77
* specific browser session.
88
*
99
* The cookie stores the Passlock challenge id and secret. The user receives
10-
* the code separately by email, so both pieces are required to complete the
11-
* flow.
10+
* the code separately, so both pieces are required to complete the flow.
1211
*/
1312

1413
// Signup and login share the same cookie because they are mutually exclusive

examples/sveltekit/src/lib/server/email/EmailChanged.svelte

Lines changed: 0 additions & 8 deletions
This file was deleted.

examples/sveltekit/src/lib/server/email/fake.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

examples/sveltekit/src/lib/server/email/index.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

examples/sveltekit/src/lib/server/email/types.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

examples/sveltekit/src/lib/server/mailbox/README.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ email one-time-code flows:
88
- email-change verification
99

1010
All three flows use Passlock mailbox challenges. The app creates a challenge,
11-
emails the one-time code to the user, stores the challenge id and secret in an
12-
HTTP-only cookie, and then verifies the submitted code against Passlock before
13-
applying the local state change.
11+
asks Passlock to send the one-time code in production, stores the challenge id
12+
and secret in an HTTP-only cookie, and then verifies the submitted code against
13+
Passlock before applying the local state change. In development, the app does
14+
not ask Passlock to send email and logs the code to the dev server console.
1415

1516
## End-to-end flow
1617

@@ -20,8 +21,9 @@ The overall flow is the same for signup, login, and email change:
2021
directory.
2122
2. The helper creates a Passlock mailbox challenge with a `purpose`, optional
2223
`userId`, and flow-specific `metadata`.
23-
3. The route sends the returned `code` by email and stores `{ challengeId,
24-
secret }` in a short-lived HTTP-only cookie.
24+
3. The shared creation helper passes `sendEmail: true` only in production, logs
25+
the returned `code` only in development, and the route stores
26+
`{ challengeId, secret }` in a short-lived HTTP-only cookie.
2527
4. The verify-code page reloads the pending challenge from that cookie using
2628
`getPendingChallengeContext(...)`.
2729
5. When the user submits the 6-digit code, the action combines:
@@ -46,7 +48,8 @@ Key responsibilities:
4648

4749
- `getPasslockMailboxChallenge(...)` loads an existing challenge from Passlock.
4850
- `createPasslockMailboxChallenge(...)` creates a challenge and normalises the
49-
rate-limit result.
51+
rate-limit result. It opts in to Passlock email delivery in production, while
52+
keeping development and tests generate-only.
5053
- `verifyPasslockMailboxChallenge(...)` verifies a challenge and returns either
5154
`MailboxChallengeVerified` or a typed verification error.
5255
- `validateMailboxChallenge(...)` applies app-level checks after loading or
@@ -109,8 +112,7 @@ Login uses only the shared base metadata:
109112
Important types:
110113

111114
- `LoginChallenge`: the validated local view of a login challenge
112-
- `CreatedLoginChallenge`: creation result used to send the email and set the
113-
pending cookie
115+
- `CreatedLoginChallenge`: creation result used to set the pending cookie
114116
- `ConsumedChallenge`: shared success result with the resolved local
115117
`SessionUser`
116118

@@ -125,8 +127,7 @@ Important types:
125127

126128
- `EmailChangeChallenge`: validated local challenge including the bound numeric
127129
`userId`
128-
- `CreatedEmailChangeChallenge`: creation result used to send the email and set
129-
the pending cookie
130+
- `CreatedEmailChangeChallenge`: creation result used to set the pending cookie
130131
- `EmailChangeSuccess`: success result after the local email address has been
131132
updated
132133

@@ -164,7 +165,7 @@ Passlock already gives each challenge its own expiration window, but this sample
164165
adds a separate `processExpiresAt` value inside challenge metadata.
165166

166167
That extra timestamp lets the app expire the whole local flow, not just the
167-
emailed code. In practice that means:
168+
one-time code. In practice that means:
168169

169170
- signup details can expire even if the Passlock code itself still exists
170171
- login/email-change routes can reject stale pending flows consistently after a
@@ -180,7 +181,7 @@ The resend endpoints reuse the same primitives:
180181
`getPendingChallengeContext(...)`
181182
- they create a fresh challenge with the relevant `createOrRefresh*Challenge`
182183
helper
183-
- they send a new email and replace the pending cookie using
184+
- they create a fresh challenge and replace the pending cookie using
184185
`resendMailboxChallenge(...)`
185186

186187
This keeps the resend routes thin while leaving all mailbox-specific validation

examples/sveltekit/src/lib/server/mailbox/emailChange.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,6 @@ export type CreatedEmailChangeChallenge = {
3838
_tag: 'CreatedChallenge';
3939
challenge: EmailChangeChallenge;
4040
secret: string;
41-
code: string;
42-
message: {
43-
html: string;
44-
text: string;
45-
};
4641
};
4742

4843
/**
@@ -51,7 +46,6 @@ export type CreatedEmailChangeChallenge = {
5146
export type EmailChangeSuccess = {
5247
_tag: 'EmailChangeSuccess';
5348
user: SessionUser;
54-
oldEmail: string;
5549
};
5650

5751
type EmailChangeMetadata = v.InferOutput<typeof BaseMetadataSchema>;
@@ -119,9 +113,7 @@ export const createOrRefreshEmailChallenge = async (input: {
119113
userId: account.userId,
120114
processExpiresAt
121115
},
122-
secret: challenge.secret,
123-
code: challenge.code,
124-
message: challenge.message
116+
secret: challenge.secret
125117
};
126118
};
127119

@@ -158,12 +150,6 @@ export const consumeEmailChallenge = async (input: {
158150
return createInvalidChallengeError('Challenge does not belong to the signed-in user');
159151
}
160152

161-
const currentAccount = await getUserById(input.userId);
162-
if (!currentAccount) {
163-
console.error('Unable to load signed-in account', { userId: input.userId });
164-
kitError(500, 'Unable to load signed-in account');
165-
}
166-
167153
const existingAccount = await getUserByEmail(challenge.email);
168154
if (existingAccount && existingAccount.userId !== input.userId) {
169155
return { _tag: '@error/DuplicateUser', email: challenge.email };
@@ -180,7 +166,6 @@ export const consumeEmailChallenge = async (input: {
180166

181167
return {
182168
_tag: 'EmailChangeSuccess',
183-
user: updatedUser,
184-
oldEmail: currentAccount.email
169+
user: updatedUser
185170
};
186171
};

examples/sveltekit/src/lib/server/mailbox/loginChallenge.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,6 @@ export type CreatedLoginChallenge = {
2828
_tag: 'CreatedChallenge';
2929
challenge: LoginChallenge;
3030
secret: string;
31-
code: string;
32-
message: {
33-
html: string;
34-
text: string;
35-
};
3631
};
3732

3833
type LoginMetadata = v.InferOutput<typeof BaseMetadataSchema>;
@@ -86,9 +81,7 @@ export const createOrRefreshLoginChallenge = async (
8681
email: challenge.email,
8782
processExpiresAt
8883
},
89-
secret: challenge.secret,
90-
code: challenge.code,
91-
message: challenge.message
84+
secret: challenge.secret
9285
};
9386
};
9487

0 commit comments

Comments
 (0)