feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention

This commit is contained in:
randogoth 2026-09-27 11:07:11 +03:00
parent eaaa3f2ede
commit c693e6fcb9
27 changed files with 1190 additions and 592 deletions

View file

@ -32,18 +32,14 @@ final accountProvider = Provider<SmolAddress?>((ref) {
return ref.watch(clientProvider).accountAddress(); return ref.watch(clientProvider).accountAddress();
}); });
class Folder extends Notifier<String> { /// One tab (Inbox/Sent/Requests) can be on screen — and simultaneously kept
@override /// alive off-screen by [TabBarView] for swiping — while another is active,
String build() => "inbox"; /// so each is parameterized by its own folder rather than sharing one
/// ambient "current folder" provider.
void select(String folder) => state = folder; final messagesForFolderProvider =
} Provider.family<List<MailRecord>, String>((ref, folder) {
final folderProvider = NotifierProvider<Folder, String>(Folder.new);
final messagesProvider = Provider<List<MailRecord>>((ref) {
ref.watch(revisionProvider); ref.watch(revisionProvider);
return ref.watch(storeProvider).listMessages(ref.watch(folderProvider)); return ref.watch(storeProvider).listMessages(folder);
}); });
final unreadProvider = Provider<int>((ref) { final unreadProvider = Provider<int>((ref) {
@ -51,6 +47,11 @@ final unreadProvider = Provider<int>((ref) {
return ref.watch(storeProvider).unreadCount(); return ref.watch(storeProvider).unreadCount();
}); });
final requestsUnreadProvider = Provider<int>((ref) {
ref.watch(revisionProvider);
return ref.watch(storeProvider).requestsUnreadCount();
});
final contactsProvider = Provider<List<(String, StoredContact)>>((ref) { final contactsProvider = Provider<List<(String, StoredContact)>>((ref) {
ref.watch(revisionProvider); ref.watch(revisionProvider);
return ref.watch(storeProvider).allContacts(); return ref.watch(storeProvider).allContacts();

View file

@ -20,9 +20,14 @@ class AppRouter extends RootStackRouter {
guards: [HomeGuard(ref)], guards: [HomeGuard(ref)],
), ),
AutoRoute( AutoRoute(
page: InboxRoute.page, page: HomeRoute.page,
path: "/inbox", path: "/home",
guards: [IdentityGuard(ref)], guards: [IdentityGuard(ref)],
children: [
AutoRoute(page: InboxRoute.page, path: "inbox", initial: true),
AutoRoute(page: ContactsRoute.page, path: "contacts"),
AutoRoute(page: SettingsRoute.page, path: "settings"),
],
), ),
AutoRoute( AutoRoute(
page: MessageDetailRoute.page, page: MessageDetailRoute.page,
@ -34,20 +39,10 @@ class AppRouter extends RootStackRouter {
path: "/compose", path: "/compose",
guards: [IdentityGuard(ref)], guards: [IdentityGuard(ref)],
), ),
AutoRoute(
page: ContactsRoute.page,
path: "/contacts",
guards: [IdentityGuard(ref)],
),
AutoRoute( AutoRoute(
page: ContactDetailRoute.page, page: ContactDetailRoute.page,
path: "/contact", path: "/contact",
guards: [IdentityGuard(ref)], guards: [IdentityGuard(ref)],
), ),
AutoRoute(
page: SettingsRoute.page,
path: "/settings",
guards: [IdentityGuard(ref)],
),
]; ];
} }

View file

@ -10,26 +10,27 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i8; import 'package:auto_route/auto_route.dart' as _i9;
import 'package:flutter/material.dart' as _i9; import 'package:flutter/material.dart' as _i10;
import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1; import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1;
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart' import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
as _i2; as _i2;
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3; import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i4; import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5;
import 'package:smol_mail/presentation/screens/message_detail_screen.dart' import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
as _i5; as _i6;
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6; import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7;
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7; import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8;
/// generated route for /// generated route for
/// [_i1.ComposeScreen] /// [_i1.ComposeScreen]
class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> { class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
ComposeRoute({ ComposeRoute({
_i9.Key? key, _i10.Key? key,
String? to, String? to,
String? subject, String? subject,
List<_i8.PageRouteInfo>? children, List<_i9.PageRouteInfo>? children,
}) : super( }) : super(
ComposeRoute.name, ComposeRoute.name,
args: ComposeRouteArgs(key: key, to: to, subject: subject), args: ComposeRouteArgs(key: key, to: to, subject: subject),
@ -38,7 +39,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
static const String name = 'ComposeRoute'; static const String name = 'ComposeRoute';
static _i8.PageInfo page = _i8.PageInfo( static _i9.PageInfo page = _i9.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<ComposeRouteArgs>( final args = data.argsAs<ComposeRouteArgs>(
@ -56,7 +57,7 @@ class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
class ComposeRouteArgs { class ComposeRouteArgs {
const ComposeRouteArgs({this.key, this.to, this.subject}); const ComposeRouteArgs({this.key, this.to, this.subject});
final _i9.Key? key; final _i10.Key? key;
final String? to; final String? to;
@ -80,11 +81,11 @@ class ComposeRouteArgs {
/// generated route for /// generated route for
/// [_i2.ContactDetailScreen] /// [_i2.ContactDetailScreen]
class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> { class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
ContactDetailRoute({ ContactDetailRoute({
_i9.Key? key, _i10.Key? key,
required String address, required String address,
List<_i8.PageRouteInfo>? children, List<_i9.PageRouteInfo>? children,
}) : super( }) : super(
ContactDetailRoute.name, ContactDetailRoute.name,
args: ContactDetailRouteArgs(key: key, address: address), args: ContactDetailRouteArgs(key: key, address: address),
@ -93,7 +94,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
static const String name = 'ContactDetailRoute'; static const String name = 'ContactDetailRoute';
static _i8.PageInfo page = _i8.PageInfo( static _i9.PageInfo page = _i9.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<ContactDetailRouteArgs>(); final args = data.argsAs<ContactDetailRouteArgs>();
@ -105,7 +106,7 @@ class ContactDetailRoute extends _i8.PageRouteInfo<ContactDetailRouteArgs> {
class ContactDetailRouteArgs { class ContactDetailRouteArgs {
const ContactDetailRouteArgs({this.key, required this.address}); const ContactDetailRouteArgs({this.key, required this.address});
final _i9.Key? key; final _i10.Key? key;
final String address; final String address;
@ -127,13 +128,13 @@ class ContactDetailRouteArgs {
/// generated route for /// generated route for
/// [_i3.ContactsScreen] /// [_i3.ContactsScreen]
class ContactsRoute extends _i8.PageRouteInfo<void> { class ContactsRoute extends _i9.PageRouteInfo<void> {
const ContactsRoute({List<_i8.PageRouteInfo>? children}) const ContactsRoute({List<_i9.PageRouteInfo>? children})
: super(ContactsRoute.name, initialChildren: children); : super(ContactsRoute.name, initialChildren: children);
static const String name = 'ContactsRoute'; static const String name = 'ContactsRoute';
static _i8.PageInfo page = _i8.PageInfo( static _i9.PageInfo page = _i9.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i3.ContactsScreen(); return const _i3.ContactsScreen();
@ -142,29 +143,45 @@ class ContactsRoute extends _i8.PageRouteInfo<void> {
} }
/// generated route for /// generated route for
/// [_i4.InboxScreen] /// [_i4.HomeScreen]
class InboxRoute extends _i8.PageRouteInfo<void> { class HomeRoute extends _i9.PageRouteInfo<void> {
const InboxRoute({List<_i8.PageRouteInfo>? children}) const HomeRoute({List<_i9.PageRouteInfo>? children})
: super(InboxRoute.name, initialChildren: children); : super(HomeRoute.name, initialChildren: children);
static const String name = 'InboxRoute'; static const String name = 'HomeRoute';
static _i8.PageInfo page = _i8.PageInfo( static _i9.PageInfo page = _i9.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i4.InboxScreen(); return const _i4.HomeScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i5.MessageDetailScreen] /// [_i5.InboxScreen]
class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> { class InboxRoute extends _i9.PageRouteInfo<void> {
const InboxRoute({List<_i9.PageRouteInfo>? children})
: super(InboxRoute.name, initialChildren: children);
static const String name = 'InboxRoute';
static _i9.PageInfo page = _i9.PageInfo(
name,
builder: (data) {
return const _i5.InboxScreen();
},
);
}
/// generated route for
/// [_i6.MessageDetailScreen]
class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
MessageDetailRoute({ MessageDetailRoute({
_i9.Key? key, _i10.Key? key,
required String folder, required String folder,
required String id, required String id,
List<_i8.PageRouteInfo>? children, List<_i9.PageRouteInfo>? children,
}) : super( }) : super(
MessageDetailRoute.name, MessageDetailRoute.name,
args: MessageDetailRouteArgs(key: key, folder: folder, id: id), args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
@ -173,11 +190,11 @@ class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
static const String name = 'MessageDetailRoute'; static const String name = 'MessageDetailRoute';
static _i8.PageInfo page = _i8.PageInfo( static _i9.PageInfo page = _i9.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<MessageDetailRouteArgs>(); final args = data.argsAs<MessageDetailRouteArgs>();
return _i5.MessageDetailScreen( return _i6.MessageDetailScreen(
key: args.key, key: args.key,
folder: args.folder, folder: args.folder,
id: args.id, id: args.id,
@ -193,7 +210,7 @@ class MessageDetailRouteArgs {
required this.id, required this.id,
}); });
final _i9.Key? key; final _i10.Key? key;
final String folder; final String folder;
@ -216,33 +233,33 @@ class MessageDetailRouteArgs {
} }
/// generated route for /// generated route for
/// [_i6.OnboardingScreen] /// [_i7.OnboardingScreen]
class OnboardingRoute extends _i8.PageRouteInfo<void> { class OnboardingRoute extends _i9.PageRouteInfo<void> {
const OnboardingRoute({List<_i8.PageRouteInfo>? children}) const OnboardingRoute({List<_i9.PageRouteInfo>? children})
: super(OnboardingRoute.name, initialChildren: children); : super(OnboardingRoute.name, initialChildren: children);
static const String name = 'OnboardingRoute'; static const String name = 'OnboardingRoute';
static _i8.PageInfo page = _i8.PageInfo( static _i9.PageInfo page = _i9.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i6.OnboardingScreen(); return const _i7.OnboardingScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i7.SettingsScreen] /// [_i8.SettingsScreen]
class SettingsRoute extends _i8.PageRouteInfo<void> { class SettingsRoute extends _i9.PageRouteInfo<void> {
const SettingsRoute({List<_i8.PageRouteInfo>? children}) const SettingsRoute({List<_i9.PageRouteInfo>? children})
: super(SettingsRoute.name, initialChildren: children); : super(SettingsRoute.name, initialChildren: children);
static const String name = 'SettingsRoute'; static const String name = 'SettingsRoute';
static _i8.PageInfo page = _i8.PageInfo( static _i9.PageInfo page = _i9.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i7.SettingsScreen(); return const _i8.SettingsScreen();
}, },
); );
} }

View file

@ -15,7 +15,7 @@ class HomeGuard extends AutoRouteGuard {
void onNavigation(NavigationResolver resolver, StackRouter router) { void onNavigation(NavigationResolver resolver, StackRouter router) {
if (ref.read(identityProvider) != null && if (ref.read(identityProvider) != null &&
ref.read(accountProvider) != null) { ref.read(accountProvider) != null) {
router.replace(InboxRoute()); router.replace(HomeRoute());
} else { } else {
resolver.next(true); resolver.next(true);
} }

View file

@ -24,6 +24,34 @@ class ContactDetailScreen extends ConsumerStatefulWidget {
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> { class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
bool resolving = false; bool resolving = false;
bool tokenBusy = false;
// §5.8: admitting or withdrawing a contact's accept token pushes the
// change to the server right away, since it only takes effect once the
// server holds the changed set.
Future<void> _toggleAccepted(bool currentlyAccepted) async {
final client = ref.read(clientProvider);
setState(() => tokenBusy = true);
try {
if (currentlyAccepted) {
await client.blockContact(widget.address);
} else {
await client.acceptContact(widget.address);
}
ref.read(revisionProvider.notifier).bump();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(currentlyAccepted
? "${widget.address} blocked; their mail now lands in requests"
: "${widget.address} accepted; their mail now lands in your main tier")),
);
}
} on SmolError catch (err) {
if (mounted) showErrorSnackBar(context, err.message);
} finally {
if (mounted) setState(() => tokenBusy = false);
}
}
Future<void> _reResolve() async { Future<void> _reResolve() async {
final client = ref.read(clientProvider); final client = ref.read(clientProvider);
@ -54,7 +82,11 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final contact = ref.watch(storeProvider).contact(widget.address); ref.watch(revisionProvider);
final store = ref.read(storeProvider);
final contact = store.contact(widget.address);
final accepted = store.accepted(widget.address);
final isAccepted = accepted != null && accepted.active;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.address)), appBar: AppBar(title: Text(widget.address)),
@ -69,6 +101,29 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
? "verified key" ? "verified key"
: "key pinned on first use"), : "key pinned on first use"),
const SizedBox(height: 20), const SizedBox(height: 20),
Text(
"Accept token (SPEC.md §5.8)",
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 5),
Text(
isAccepted
? "This contact's mail lands in your main tier."
: "This contact's mail lands in requests until accepted.",
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(height: 10),
tokenBusy
? const Center(child: CircularProgressIndicator())
: TextButton.icon(
onPressed: () => _toggleAccepted(isAccepted),
icon: Icon(isAccepted ? Icons.block : Icons.check_circle_outline,
size: 18),
label: Text(isAccepted ? "Block" : "Accept"),
),
const SizedBox(height: 20),
const Divider(height: 1),
const SizedBox(height: 20),
_field(context, "fingerprint", fingerprint(contact.key)), _field(context, "fingerprint", fingerprint(contact.key)),
_field(context, "public key", b32encode(contact.key)), _field(context, "public key", b32encode(contact.key)),
Row( Row(

View file

@ -82,6 +82,7 @@ class ContactsScreen extends ConsumerWidget {
itemCount: contacts.length, itemCount: contacts.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final (address, contact) = contacts[i]; final (address, contact) = contacts[i];
final accepted = ref.read(storeProvider).accepted(address);
return ListTile( return ListTile(
splashColor: Theme.of(context).extension<AppColors>()!.highlight, splashColor: Theme.of(context).extension<AppColors>()!.highlight,
onTap: () => AutoRouter.of(context) onTap: () => AutoRouter.of(context)
@ -93,12 +94,15 @@ class ContactsScreen extends ConsumerWidget {
), ),
trailing: Wrap( trailing: Wrap(
spacing: 6, spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_chip(context, contact.verified ? "verified" : "tofu", _chip(context, contact.verified ? "verified" : "tofu",
ok: contact.verified), ok: contact.verified),
if (contact.history.isNotEmpty) if (contact.history.isNotEmpty)
_chip(context, "${contact.history.length} previous", _chip(context, "${contact.history.length} previous",
ok: false), ok: false),
if (accepted != null && accepted.active)
_chip(context, "accepted", ok: true),
], ],
), ),
); );

View file

@ -0,0 +1,86 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/presentation/theme/breakpoints.dart";
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
class _Destination {
final Widget icon;
final String label;
const _Destination({required this.icon, required this.label});
}
/// The app's only persistent navigation surface: a bottom [NavigationBar] on
/// phone-width screens, a side [NavigationRail] on desktop-width ones — no
/// hamburger drawer at either width. Mail/Contacts/Settings are switched by
/// tapping only, never by swipe, so a horizontal gesture inside a
/// destination (e.g. the Inbox/Sent/Requests tab bar) never changes section.
@RoutePage()
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final unread = ref.watch(unreadProvider) + ref.watch(requestsUnreadProvider);
final appColors = Theme.of(context).extension<AppColors>()!;
final mailIcon = Icon(FlashMailIcons.inbox);
final destinations = [
_Destination(
icon: unread > 0 ? Badge(label: Text("$unread"), child: mailIcon) : mailIcon,
label: "Mail",
),
const _Destination(icon: Icon(Icons.contacts), label: "Contacts"),
const _Destination(icon: Icon(Icons.settings), label: "Settings"),
];
return AutoTabsRouter.builder(
routes: const [InboxRoute(), ContactsRoute(), SettingsRoute()],
builder: (context, children, tabsRouter) {
final body = IndexedStack(index: tabsRouter.activeIndex, children: children);
final wide = MediaQuery.sizeOf(context).width >= kDesktopBreakpoint;
if (!wide) {
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: tabsRouter.setActiveIndex,
indicatorColor: appColors.highlight,
destinations: [
for (final d in destinations)
NavigationDestination(icon: d.icon, label: d.label),
],
),
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: tabsRouter.setActiveIndex,
backgroundColor: appColors.cardFill,
useIndicator: true,
indicatorColor: appColors.highlight,
labelType: NavigationRailLabelType.all,
destinations: [
for (final d in destinations)
NavigationRailDestination(icon: d.icon, label: Text(d.label)),
],
),
const VerticalDivider(width: 1),
Expanded(child: body),
],
),
);
},
);
}
}

View file

@ -4,18 +4,46 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/widgets/drawer/app_drawer.dart";
import "package:smol_mail/presentation/widgets/image_banner.dart"; import "package:smol_mail/presentation/widgets/image_banner.dart";
import "package:smol_mail/presentation/widgets/message/message_list.dart"; import "package:smol_mail/presentation/widgets/message/message_list.dart";
import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/errors.dart";
const _folders = ["inbox", "requests", "sent"];
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps /// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
/// fetch, which pulls everything and acknowledges what verifies. /// fetch, which pulls everything and acknowledges what verifies. One FETCH
/// call populates both Inbox and Requests (they're tiers of the same
/// mailbox), so both — but not the purely local Sent folder — offer fetch.
@RoutePage() @RoutePage()
class InboxScreen extends ConsumerWidget { class InboxScreen extends ConsumerStatefulWidget {
const InboxScreen({super.key}); const InboxScreen({super.key});
@override
ConsumerState<InboxScreen> createState() => _InboxScreenState();
}
class _InboxScreenState extends ConsumerState<InboxScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: _folders.length, vsync: this)
..addListener(() {
// Fires once settled, whether the change came from a tap or a swipe;
// only the AppBar's fetch button depends on which tab is active.
if (!_tabController.indexIsChanging) setState(() {});
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _fetch(BuildContext context, WidgetRef ref) async { Future<void> _fetch(BuildContext context, WidgetRef ref) async {
final client = ref.read(clientProvider); final client = ref.read(clientProvider);
try { try {
@ -36,33 +64,14 @@ class InboxScreen extends ConsumerWidget {
} }
} }
@override Widget _folderView(BuildContext context, WidgetRef ref, String folder) {
Widget build(BuildContext context, WidgetRef ref) { final messages = ref.watch(messagesForFolderProvider(folder));
final messages = ref.watch(messagesProvider); final canFetch = folder != "sent";
final folder = ref.watch(folderProvider); final onRefresh = canFetch ? () => _fetch(context, ref) : () => Future.value();
return Scaffold( if (messages.isEmpty) {
appBar: AppBar( return RefreshIndicator(
title: Text(folder == "inbox" ? "Inbox" : "Sent"), onRefresh: onRefresh,
actions: [
if (folder == "inbox")
IconButton(
tooltip: "Fetch",
onPressed: () => _fetch(context, ref),
icon: const Icon(Icons.cloud_download),
),
],
),
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
child: const Icon(Icons.edit),
),
body: messages.isEmpty
? RefreshIndicator(
onRefresh: () => folder == "inbox"
? _fetch(context, ref)
: Future.value(),
child: ListView( child: ListView(
children: const [ children: const [
ImageBanner( ImageBanner(
@ -71,11 +80,46 @@ class InboxScreen extends ConsumerWidget {
), ),
], ],
), ),
) );
: MessageList( }
onRefresh: () => folder == "inbox" return MessageList(folder: folder, onRefresh: onRefresh);
? _fetch(context, ref) }
: Future.value(),
@override
Widget build(BuildContext context) {
final unread = ref.watch(unreadProvider);
final requestsUnread = ref.watch(requestsUnreadProvider);
final canFetch = _folders[_tabController.index] != "sent";
return Scaffold(
appBar: AppBar(
title: const Text("kirakira"),
actions: [
if (canFetch)
IconButton(
tooltip: "Fetch",
onPressed: () => _fetch(context, ref),
icon: const Icon(Icons.cloud_download),
),
],
bottom: TabBar(
controller: _tabController,
tabs: [
Tab(text: unread > 0 ? "Inbox ($unread)" : "Inbox"),
Tab(text: requestsUnread > 0 ? "Requests ($requestsUnread)" : "Requests"),
const Tab(text: "Sent"),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
child: const Icon(Icons.edit),
),
body: TabBarView(
controller: _tabController,
children: [
for (final folder in _folders) _folderView(context, ref, folder),
],
), ),
); );
} }

View file

@ -10,9 +10,10 @@ import "package:smol_mail/presentation/widgets/message/message_view.dart";
import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/store.dart";
/// Fetched mail is acknowledged off the server during fetch, so deleting here /// Deleting removes the local copy, and the server's too if "leave mail on
/// removes only the local sealed copy. /// server" left one there to remove (settings; SPEC.md §10).
@RoutePage() @RoutePage()
class MessageDetailScreen extends ConsumerWidget { class MessageDetailScreen extends ConsumerWidget {
final String folder; final String folder;
@ -24,8 +25,14 @@ class MessageDetailScreen extends ConsumerWidget {
required this.id, required this.id,
}); });
Future<void> _delete(BuildContext context, WidgetRef ref) async { Future<void> _delete(
await ref.read(storeProvider).deleteMessage(folder, id); BuildContext context, WidgetRef ref, MailRecord record) async {
try {
await ref.read(clientProvider).deleteMessage(folder, record);
} on SmolError catch (err) {
if (context.mounted) showErrorSnackBar(context, err.message);
return; // keep the local copy if a needed server delete failed
}
ref.read(revisionProvider.notifier).bump(); ref.read(revisionProvider.notifier).bump();
if (context.mounted) { if (context.mounted) {
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();
@ -109,8 +116,8 @@ class MessageDetailScreen extends ConsumerWidget {
), ),
actions: [ actions: [
IconButton( IconButton(
tooltip: "Delete local copy", tooltip: "Delete",
onPressed: () => _delete(context, ref), onPressed: () => _delete(context, ref, record),
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
), ),
], ],
@ -118,6 +125,7 @@ class MessageDetailScreen extends ConsumerWidget {
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: MessageView( child: MessageView(
folder: folder,
record: record, record: record,
opened: opened, opened: opened,
onNameSender: opened.sender == null onNameSender: opened.sender == null

View file

@ -29,12 +29,17 @@ enum _Step { welcome, backup, restore, register }
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> { class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
_Step step = _Step.welcome; _Step step = _Step.welcome;
Uint8List? createdSeed; Uint8List? createdMaster;
bool busy = false; bool busy = false;
// The register step doubles as "pin a key to finish recalling" when a // The register step doubles as "pin a key to finish recalling" when a
// restore's recall can't proceed without one yet — same fields, different // restore's recall can't proceed without one yet — same fields, different
// framing and default action, not the generic "register a new address" copy. // framing and default action, not the generic "register a new address" copy.
bool recallIntent = false; bool recallIntent = false;
// Set alongside recallIntent when we got here from Restore rather than from
// "Already registered? Recall": there is no well-defined "register a new
// address instead" fallback for a restored master until its rotation index
// is known, so that escape hatch is hidden in this case (§2).
bool restoreIntent = false;
final seedController = TextEditingController(); final seedController = TextEditingController();
final restoreAddressController = TextEditingController(); final restoreAddressController = TextEditingController();
@ -62,11 +67,11 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
} }
// Reaching onboarding at all means HomeGuard already found no complete // Reaching onboarding at all means HomeGuard already found no complete
// identity+account, so a seed still sitting in the store here can only be // identity+account, so a master still sitting in the store here can only be
// an abandoned attempt from earlier in this same flow (wrong seed, failed // an abandoned attempt from earlier in this same flow (wrong master, failed
// recall, "Back") — safe to replace rather than reject. // recall, "Back") — safe to replace rather than reject.
// wipe() is fire-and-forget here, like every other store write in this // wipe() is fire-and-forget here, like every other store write in this
// screen (setIdentity, pinServer, ...) — Hive updates its in-memory state // screen (setMaster, pinServer, ...) — Hive updates its in-memory state
// synchronously and persists to disk in the background, so the identity // synchronously and persists to disk in the background, so the identity
// check right after is already consistent without awaiting the write. // check right after is already consistent without awaiting the write.
void _clearAbandonedIdentity() { void _clearAbandonedIdentity() {
@ -80,9 +85,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
_clearAbandonedIdentity(); _clearAbandonedIdentity();
final client = ref.read(clientProvider); final client = ref.read(clientProvider);
try { try {
final fresh = client.createIdentity(); final master = client.createIdentity();
setState(() { setState(() {
createdSeed = fresh.seed; createdMaster = master;
step = _Step.backup; step = _Step.backup;
}); });
} on Exception catch (err) { } on Exception catch (err) {
@ -94,74 +99,49 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
setState(() => step = _Step.restore); setState(() => step = _Step.restore);
} }
// Restoring must succeed on its own even if recall fails (server not // §2: a master alone does not say which rotation index a server bound, so
// pinned yet, offline, typo) — recall can always be retried from the // restoring resolves the address and walks indices to find it — restore
// register step or settings afterward. // always ends in a recall, never a bare local step.
void _submitRestore() { Future<void> _submitRestore() async {
_clearAbandonedIdentity();
final client = ref.read(clientProvider);
try {
client.restoreIdentity(seedController.text);
} on Exception catch (err) {
_showError(err);
return;
}
final addressText = restoreAddressController.text.trim(); final addressText = restoreAddressController.text.trim();
if (addressText.isEmpty) { if (addressText.isEmpty) {
setState(() => step = _Step.register); _showError(const SmolError("enter the address this master was registered under"));
return; return;
} }
_clearAbandonedIdentity();
// The register step has its own address field (it also needs a server // The register step has its own address field (it also needs a server
// key, which restore doesn't collect) — carry over what was already // key, which restore doesn't collect) — carry over what was already
// typed rather than making the user re-enter it. // typed rather than making the user re-enter it.
addressController.text = addressText; addressController.text = addressText;
final SmolAddress addr; final client = ref.read(clientProvider);
setState(() => busy = true);
try { try {
addr = parseAddress(addressText); await client.restoreAndRecall(seedController.text, addressText);
} on Exception catch (err) {
// A genuine typo, distinct from the merely-unpinned case below — still
// worth an error, but land on the same recall-oriented step to fix it.
_showError(err);
setState(() {
recallIntent = true;
step = _Step.register;
});
return;
}
// Recall needs the host pinned first (SPEC.md §4). That's the expected,
// common state right after a restore — not an error — so check for it
// up front instead of letting recallAccount fail and surfacing that as
// one: this address just needs a key before its first recall can proceed.
if (ref.read(storeProvider).serverPin(addr.host) == null) {
setState(() {
recallIntent = true;
step = _Step.register;
});
return;
}
() async {
try {
await client.recallAccount(addressText);
if (!mounted) return; if (!mounted) return;
ref.read(revisionProvider.notifier).bump(); ref.read(revisionProvider.notifier).bump();
AutoRouter.of(context).replace(InboxRoute()); AutoRouter.of(context).replace(HomeRoute());
} on Exception catch (err) { } on Exception catch (err) {
if (!mounted) return; if (!mounted) return;
// Most often the host just isn't pinned yet (SPEC.md §4) — the expected
// state right after a restore, not a dead end — so land on the
// recall-framed register step to collect a key and retry.
_showError(err); _showError(err);
setState(() { setState(() {
recallIntent = true; recallIntent = true;
restoreIntent = true;
step = _Step.register; step = _Step.register;
}); });
} finally {
if (mounted) setState(() => busy = false);
} }
}();
} }
Future<void> _submitRegistration() async { Future<void> _submitRegistration() async {
await _submit(recall: false); await _submit(recall: false);
} }
// Restoring a seed on a new device knows the identity but not the address it // Recall binds a restored or already-created identity to its registered
// was registered under; recall binds it without re-REGISTER. // address without re-REGISTER.
Future<void> _submitRecall() async { Future<void> _submitRecall() async {
await _submit(recall: true); await _submit(recall: true);
} }
@ -178,7 +158,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
if (serverKey.isNotEmpty) { if (serverKey.isNotEmpty) {
client.pinServer(address.host, serverKey); client.pinServer(address.host, serverKey);
} }
if (recall) { if (recall && restoreIntent) {
await client.restoreAndRecall(seedController.text, address.short);
} else if (recall) {
await client.recallAccount(address.short); await client.recallAccount(address.short);
} else { } else {
await client.registerAccount(address.short, await client.registerAccount(address.short,
@ -186,7 +168,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
} }
if (mounted) { if (mounted) {
ref.read(revisionProvider.notifier).bump(); ref.read(revisionProvider.notifier).bump();
AutoRouter.of(context).replace(InboxRoute()); AutoRouter.of(context).replace(HomeRoute());
} }
} catch (err) { } catch (err) {
_showError(err); _showError(err);
@ -248,20 +230,21 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
} }
Widget _backup(BuildContext context) { Widget _backup(BuildContext context) {
final seed = createdSeed!; final master = createdMaster!;
final seedHex = hex(seed); final masterHex = hex(master);
final publicKey = ref.read(clientProvider).identity!.publicKey;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_header(context, "Back up this seed; it is the only secret."), _header(context, "Back up this master secret; it is the only secret."),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}", "fingerprint:\n${fingerprint(publicKey)}",
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
SelectableText( SelectableText(
seedHex, masterHex,
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodySmall! .bodySmall!
@ -269,14 +252,14 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await Clipboard.setData(ClipboardData(text: seedHex)); await Clipboard.setData(ClipboardData(text: masterHex));
if (mounted) { if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar( ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text("Seed copied to clipboard")), const SnackBar(content: Text("Master secret copied to clipboard")),
); );
} }
}, },
child: const Text("Copy seed"), child: const Text("Copy master secret"),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
PrimaryButton( PrimaryButton(
@ -292,7 +275,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_header(context, "Enter the 32-byte seed as 64 hex characters."), _header(context, "Enter the 32-byte master secret as 64 hex characters."),
const SizedBox(height: 20), const SizedBox(height: 20),
TextField( TextField(
controller: seedController, controller: seedController,
@ -306,7 +289,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
TextField( TextField(
controller: restoreAddressController, controller: restoreAddressController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Address (if already registered)", labelText: "Address this master was registered under",
hintText: "alice@example.org", hintText: "alice@example.org",
filled: true, filled: true,
fillColor: Theme.of(context).extension<AppColors>()!.cardFill, fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
@ -314,13 +297,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
PrimaryButton( PrimaryButton(
onPressed: _submitRestore, onPressed: busy ? () {} : _submitRestore,
child: const Text("Restore"), child: busy ? const SmallLoadingSpinner() : const Text("Restore"),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
SecondaryButton( SecondaryButton(
text: "Back", text: "Back",
onPressed: () => setState(() => step = _Step.welcome), onPressed: busy ? () {} : () => setState(() => step = _Step.welcome),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
@ -377,6 +360,10 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
? const SmallLoadingSpinner() ? const SmallLoadingSpinner()
: Text(recallIntent ? "Pin and Recall" : "Pin and Register"), : Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
), ),
// Restoring a master has no well-defined "register instead" fallback
// until its rotation index is resolved (§2), so that escape hatch is
// only offered from the fresh-identity path.
if (!restoreIntent)
TextButton( TextButton(
onPressed: busy onPressed: busy
? () {} ? () {}

View file

@ -88,8 +88,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
if (mounted) { if (mounted) {
// The export payload is sealed now (v2), so the counts for this // The export payload is sealed now (v2), so the counts for this
// notice come straight from the store rather than the ciphertext. // notice come straight from the store rather than the ciphertext.
final messages = final messages = store.listMessages("inbox").length +
store.listMessages("inbox").length + store.listMessages("sent").length; store.listMessages("requests").length +
store.listMessages("sent").length;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("exported $messages messages, " SnackBar(content: Text("exported $messages messages, "
"${store.allContacts().length} contacts, " "${store.allContacts().length} contacts, "
@ -207,7 +208,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final account = ref.watch(accountProvider); final account = ref.watch(accountProvider);
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings"))); if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
final seedHex = hex(me.seed); final masterHex = hex(store.master()!);
final share = account?.uri(me.publicKey); final share = account?.uri(me.publicKey);
final pins = store.allPins(); final pins = store.allPins();
@ -249,12 +250,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
context, context,
borderColor: Theme.of(context).colorScheme.error, borderColor: Theme.of(context).colorScheme.error,
children: [ children: [
Text("seed", style: Theme.of(context).textTheme.labelSmall), Text("master secret", style: Theme.of(context).textTheme.labelSmall),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: SelectableText( child: SelectableText(
seedShown ? seedHex : "•" * 64, seedShown ? masterHex : "•" * 64,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
@ -266,12 +267,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
IconButton( IconButton(
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
onPressed: () => onPressed: () =>
Clipboard.setData(ClipboardData(text: seedHex)), Clipboard.setData(ClipboardData(text: masterHex)),
), ),
], ],
), ),
Text( Text(
"back this seed up; it is the only secret", "back this master secret up; it is the only secret",
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.labelSmall! .labelSmall!
@ -279,12 +280,31 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
"retired keys: ${store.identities().length - 1} (kept to read old mail)", "rotations: ${store.rotations()} (superseded keys kept to read old mail)",
style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 4),
Text(
"accepted correspondents: ${store.allAccepted().where((e) => e.$2.active).length}"
"${store.syncOk() ? "" : " (not yet pushed to the server — accept them again)"}",
style: Theme.of(context).textTheme.labelSmall), style: Theme.of(context).textTheme.labelSmall),
], ],
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
const Divider(height: 1), const Divider(height: 1),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text("Leave mail on server"),
subtitle: const Text(
"Fetch keeps a copy on the server instead of deleting it. "
"Delete a message here to remove it from both."),
value: store.leaveOnServer(),
onChanged: (value) {
store.setLeaveOnServer(value);
setState(() {});
},
),
const SizedBox(height: 10),
const Divider(height: 1),
const SizedBox(height: 20), const SizedBox(height: 20),
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium), Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10), const SizedBox(height: 10),

View file

@ -0,0 +1,4 @@
/// Below this logical width the home shell shows a bottom [NavigationBar]
/// (phone); at or above it, a side [NavigationRail] (desktop). Matches
/// Material 3's compact/medium width class boundary.
const double kDesktopBreakpoint = 600;

View file

@ -1,35 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter/material.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/presentation/widgets/drawer/identity_header.dart";
import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
class AppDrawer extends ConsumerWidget {
const AppDrawer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final identity = ref.watch(identityProvider);
final account = ref.watch(accountProvider);
final address = account?.short ?? "";
final fp = identity == null ? "" : fingerprint(identity.publicKey);
return Drawer(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
IdentityHeader(address: address, fingerprint: fp),
const SizedBox(height: 50),
const DrawerList(),
],
),
),
);
}
}

View file

@ -1,63 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:auto_route/auto_route.dart";
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
import "package:smol_mail/presentation/widgets/drawer/drawer_list_tile.dart";
class DrawerList extends ConsumerWidget {
const DrawerList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final unread = ref.watch(unreadProvider);
return Padding(
padding: const EdgeInsets.only(left: 25),
child: Column(
children: <Widget>[
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: FlashMailIcons.inbox,
title: "Inbox${unread > 0 ? " ($unread)" : ""}",
onTap: () {
ref.read(folderProvider.notifier).select("inbox");
ref.read(revisionProvider.notifier).bump();
Navigator.of(context).pop();
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.outgoing_mail,
title: "Sent",
onTap: () {
ref.read(folderProvider.notifier).select("sent");
ref.read(revisionProvider.notifier).bump();
Navigator.of(context).pop();
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.contacts,
title: "Contacts",
onTap: () {
Navigator.of(context).pop();
AutoRouter.of(context).push(ContactsRoute());
},
),
DrawerListTile(
color: Theme.of(context).primaryColor,
icon: Icons.settings,
title: "Settings",
onTap: () {
Navigator.of(context).pop();
AutoRouter.of(context).push(SettingsRoute());
},
),
],
),
);
}
}

View file

@ -1,42 +0,0 @@
import "package:flutter/material.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
class DrawerListTile extends StatelessWidget {
final Color color;
final IconData icon;
final String title;
final void Function() onTap;
const DrawerListTile({
super.key,
required this.color,
required this.icon,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColors>()!;
return Theme(
data: Theme.of(context).copyWith(
splashColor: appColors.highlight,
highlightColor: appColors.cardFill,
),
child: ListTile(
onTap: onTap,
leading: Icon(icon, color: color, size: 18),
title: Text(
title,
style: TextStyle(
fontFamily: "Inter",
color: color,
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
);
}
}

View file

@ -1,64 +0,0 @@
import "package:flutter/material.dart";
import "package:smol_mail/shared/utils/format.dart";
/// Identity summary in the drawer: the address is what gets shared, the
/// fingerprint is what gets verified.
class IdentityHeader extends StatelessWidget {
final String address;
final String fingerprint;
const IdentityHeader({
super.key,
required this.address,
required this.fingerprint,
});
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
radius: 35,
child: Text(
address.isEmpty ? "?" : address[0].toUpperCase(),
style:
Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30),
),
),
const SizedBox(height: 20),
InkWell(
onTap: () => copyText(context, address),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
address.isEmpty ? "(not registered)" : address,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
),
const SizedBox(height: 5),
InkWell(
onTap: () => copyText(context, fingerprint),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
"fp: $fingerprint",
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
),
],
);
}
}

View file

@ -5,21 +5,23 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/widgets/message/message_tile.dart"; import "package:smol_mail/presentation/widgets/message/message_tile.dart";
class MessageList extends ConsumerWidget { class MessageList extends ConsumerWidget {
final String folder;
final Future<void> Function() onRefresh; final Future<void> Function() onRefresh;
const MessageList({ const MessageList({
super.key, super.key,
required this.folder,
required this.onRefresh, required this.onRefresh,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider); final messages = ref.watch(messagesForFolderProvider(folder));
return RefreshIndicator( return RefreshIndicator(
onRefresh: onRefresh, onRefresh: onRefresh,
child: ListView.builder( child: ListView.builder(
itemBuilder: (ctx, i) => MessageTile(record: messages[i]), itemBuilder: (ctx, i) => MessageTile(folder: folder, record: messages[i]),
itemCount: messages.length, itemCount: messages.length,
), ),
); );

View file

@ -11,13 +11,13 @@ import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart"; import "package:smol_mail/smol/store.dart";
class MessageTile extends ConsumerWidget { class MessageTile extends ConsumerWidget {
final String folder;
final MailRecord record; final MailRecord record;
const MessageTile({super.key, required this.record}); const MessageTile({super.key, required this.folder, required this.record});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final folder = ref.watch(folderProvider);
final store = ref.watch(storeProvider); final store = ref.watch(storeProvider);
final opened = ref.read(clientProvider).describe(record); final opened = ref.read(clientProvider).describe(record);
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
@ -31,7 +31,7 @@ class MessageTile extends ConsumerWidget {
} else { } else {
who = "?"; who = "?";
} }
final unread = folder == "inbox" && !store.isRead(record.id); final unread = folder != "sent" && !store.isRead(record.id);
final errorText = opened.error != null ? "<unreadable>" : null; final errorText = opened.error != null ? "<unreadable>" : null;
final subject = final subject =
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject); errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
@ -44,7 +44,7 @@ class MessageTile extends ConsumerWidget {
splashColor: appColors.highlight, splashColor: appColors.highlight,
focusColor: appColors.cardFill, focusColor: appColors.cardFill,
onTap: () { onTap: () {
if (folder == "inbox") { if (folder != "sent") {
store.markRead(record.id); store.markRead(record.id);
ref.read(revisionProvider.notifier).bump(); ref.read(revisionProvider.notifier).bump();
} }

View file

@ -15,12 +15,14 @@ import "package:smol_mail/smol/store.dart";
/// The opened message: trust row (fingerprint and how the sender's key is /// The opened message: trust row (fingerprint and how the sender's key is
/// known), frontmatter fields, and the plain body — smol mail has no HTML. /// known), frontmatter fields, and the plain body — smol mail has no HTML.
class MessageView extends ConsumerWidget { class MessageView extends ConsumerWidget {
final String folder;
final MailRecord record; final MailRecord record;
final OpenedRecord opened; final OpenedRecord opened;
final void Function(Uint8List senderKey)? onNameSender; final void Function(Uint8List senderKey)? onNameSender;
const MessageView({ const MessageView({
super.key, super.key,
required this.folder,
required this.record, required this.record,
required this.opened, required this.opened,
this.onNameSender, this.onNameSender,
@ -38,18 +40,19 @@ class MessageView extends ConsumerWidget {
); );
} }
final folder = ref.watch(folderProvider);
final store = ref.watch(storeProvider); final store = ref.watch(storeProvider);
final client = ref.read(clientProvider); final client = ref.read(clientProvider);
// Sent copies carry no signature from a third party; the trust row then // Sent copies carry no signature from a third party; the trust row then
// just says what they are. // just says what they are.
final isSent = folder == "sent"; final isSent = folder == "sent";
final isRequest = folder == "requests";
final senderKey = opened.sender; final senderKey = opened.sender;
final known = senderKey == null ? null : store.addressForKey(senderKey); final known = senderKey == null ? null : store.addressForKey(senderKey);
final contact = known == null ? null : store.contact(known); final contact = known == null ? null : store.contact(known);
final verifiedSender = contact?.verified ?? false; final verifiedSender = contact?.verified ?? false;
final replyTo = isSent ? null : client.replyAddress(opened); final replyTo = isSent ? null : client.replyAddress(opened);
final accepted = known == null ? null : store.accepted(known);
final subject = final subject =
opened.subject.isEmpty ? "(no subject)" : opened.subject; opened.subject.isEmpty ? "(no subject)" : opened.subject;
@ -69,6 +72,10 @@ class MessageView extends ConsumerWidget {
: "key bound to no address", : "key bound to no address",
alert: known == null, alert: known == null,
), ),
if (isRequest) ...[
const SizedBox(height: 10),
_badge(context, "arrived without an accept token (SPEC.md §5.8)", alert: true),
],
if (!isSent && senderKey != null) ...[ if (!isSent && senderKey != null) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"), _keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
@ -78,7 +85,7 @@ class MessageView extends ConsumerWidget {
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")), isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
_field(context, "Date", formatTime(opened.time)), _field(context, "Date", formatTime(opened.time)),
for (final entry in opened.fields.entries) for (final entry in opened.fields.entries)
if (entry.key != "Subject" && entry.key != "Reply-To") if (entry.key != "subject" && entry.key != "reply-to" && entry.key != "accept")
_field(context, entry.key, entry.value), _field(context, entry.key, entry.value),
if (!isSent && replyTo != null) if (!isSent && replyTo != null)
Row( Row(
@ -114,6 +121,27 @@ class MessageView extends ConsumerWidget {
child: const Text("Name this sender"), child: const Text("Name this sender"),
), ),
), ),
if (isRequest && known != null && !(accepted?.active ?? false))
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
icon: const Icon(Icons.check_circle_outline, size: 18),
label: const Text("Accept sender"),
onPressed: () async {
try {
await client.acceptContact(known);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("$known accepted")),
);
}
} catch (err) {
if (context.mounted) showErrorSnackBar(context, err.toString());
}
},
),
),
const SizedBox(height: 10), const SizedBox(height: 10),
const Divider(height: 1), const Divider(height: 1),
const SizedBox(height: 20), const SizedBox(height: 20),

View file

@ -36,7 +36,7 @@ class OpenedRecord {
const OpenedRecord(this.id, const OpenedRecord(this.id,
{this.sender, this.time, this.fields = const {}, this.body = "", this.error}); {this.sender, this.time, this.fields = const {}, this.body = "", this.error});
String get subject => error == null ? (fields["Subject"] ?? "") : ""; String get subject => error == null ? (fields["subject"] ?? "") : "";
} }
class SmolClient { class SmolClient {
@ -58,6 +58,8 @@ class SmolClient {
SmolIdentity? get identity => store.identity(); SmolIdentity? get identity => store.identity();
Uint8List? get master => store.master();
SmolAddress? accountAddress() { SmolAddress? accountAddress() {
final account = store.account(); final account = store.account();
if (account == null) return null; if (account == null) return null;
@ -91,25 +93,51 @@ class SmolClient {
// --- identity setup ------------------------------------------------------------ // --- identity setup ------------------------------------------------------------
SmolIdentity createIdentity() { /// A fresh master secret at rotation index 0. Only this local step; nothing
final fresh = newIdentity(); /// is sent until [registerAccount].
store.setIdentity(fresh.seed); Uint8List createIdentity() {
final fresh = randomBytes(keyLen);
store.setMaster(fresh);
return fresh; return fresh;
} }
SmolIdentity restoreIdentity(String seedHex) { /// §2: a master alone does not say which rotation index a server has bound,
Uint8List seed; /// so restoring resolves the address and walks indices 0..[maxChain] until
/// one derives the key RESOLVE returned. Also binds "account" locally, like
/// [recallAccount] — restoring on a new device knows the identity but not
/// the address it was registered under.
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
Uint8List master;
try { try {
seed = unhex(seedHex.trim()); master = unhex(masterHex.trim());
} on Exception { } on Exception {
throw const SmolError("seed must be 64 hex characters"); throw const SmolError("master must be 64 hex characters");
} }
if (seed.length != keyLen) { if (master.length != keyLen) {
throw SmolError("seed is ${seed.length} bytes, expected $keyLen"); throw SmolError("master is ${master.length} bytes, expected $keyLen");
} }
final restored = identityFromSeed(seed); final addr = parseAddress(addressText);
store.setIdentity(seed); final opened = await connect(addr, requirePin: true);
return restored; Uint8List current;
try {
current = (await resolveOp(opened.session, addr.user)).identity;
} finally {
opened.session.wire.close();
}
int? found;
for (var n = 0; n <= maxChain; n++) {
if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) {
found = n;
break;
}
}
if (found == null) {
throw SmolError("the key bound to ${addr.short} is not derived from "
"this master within $maxChain rotations");
}
store.restoreMaster(master, found);
store.setAccount(addr);
return addr;
} }
void pinServer(String host, String keyB32) { void pinServer(String host, String keyB32) {
@ -126,7 +154,7 @@ class SmolClient {
final addr = parseAddress(addressText); final addr = parseAddress(addressText);
final opened = await connect(addr, requirePin: true); final opened = await connect(addr, requirePin: true);
try { try {
await registerOp(opened.session, addr.user, me, await registerOp(opened.session, opened.serverStatic, addr.user, me,
RegisterOptions(token: token)); RegisterOptions(token: token));
} finally { } finally {
opened.session.wire.close(); opened.session.wire.close();
@ -134,7 +162,7 @@ class SmolClient {
store.setAccount(addr); store.setAccount(addr);
} }
/// Restoring a seed brings back the identity, not the memory of what /// Restoring a master brings back the identity, not the memory of what
/// address a *different device* registered it under — "account" is /// address a *different device* registered it under — "account" is
/// local-only state, never asked of the server. This binds it without /// local-only state, never asked of the server. This binds it without
/// REGISTER: RESOLVE the address and require it name this exact key, so a /// REGISTER: RESOLVE the address and require it name this exact key, so a
@ -162,46 +190,178 @@ class SmolClient {
Future<FetchSummary> fetch() async { Future<FetchSummary> fetch() async {
final me = identity; final me = identity;
final master = this.master;
final addr = accountAddress(); final addr = accountAddress();
if (me == null) throw const SmolError("no identity yet"); if (me == null || master == null) throw const SmolError("no identity yet");
if (addr == null) { if (addr == null) {
throw const SmolError("not registered; register an address first"); throw const SmolError("not registered; register an address first");
} }
var stored = 0; var stored = 0;
final rejected = <String>[]; final rejected = <String>[];
// §10: acknowledging (deleting) is the default; "leave mail on server"
// pages forward by cursor instead, so already-fetched mail is never
// re-downloaded even though it isn't deleted (store.storeIfNew also
// dedupes, as a second line of defense).
final leaveOnServer = store.leaveOnServer();
var (afterTime, afterId) = store.cursor();
final opened = await connect(addr, requirePin: true); final opened = await connect(addr, requirePin: true);
try { try {
await authenticate(opened.session, opened.handshakeHash, addr.user, me); final (sync, tokens) = store.tokenSet(master);
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
sync: sync, tokens: tokens);
while (true) { while (true) {
final records = await fetchOp(opened.session); final records = await fetchOp(opened.session, afterTime, afterId);
if (records.isEmpty) break; if (records.isEmpty) break;
final acked = <Uint8List>[]; final acked = <Uint8List>[];
for (final record in records) { for (final record in records) {
afterTime = record.receivedAt;
afterId = record.id;
OpenedMessage msg;
try { try {
if (!timingSafeEqual(messageId(record.envelope), record.id)) { if (!timingSafeEqual(messageId(record.envelope), record.id)) {
throw const SmolError("id does not match the envelope"); throw const SmolError("id does not match the envelope");
} }
unseal(store.identities(), record.envelope); msg = unseal(store.identities(), record.envelope);
} on SmolError catch (err) { } on SmolError catch (err) {
// Left on the server rather than destroyed, so a client-side bug // Left on the server rather than destroyed, so a client-side bug
// cannot lose mail. // cannot lose mail.
rejected.add("${hex(record.id)}: ${err.message}"); rejected.add("${hex(record.id)}: ${err.message}");
continue; continue;
} }
final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope, final fresh = await store.storeIfNew(
receivedAt: record.receivedAt)); "inbox",
if (fresh != null) stored++; MailRecord(hex(record.id), record.envelope,
receivedAt: record.receivedAt,
tier: record.isRequest ? tierRequests : tierMain,
keptOnServer: leaveOnServer));
if (fresh != null) {
stored++;
_learnToken(msg);
}
acked.add(record.id); acked.add(record.id);
} }
if (acked.isEmpty) break; if (leaveOnServer) {
// Persisted per batch, so an interrupted fetch resumes here rather
// than re-paging from the start next time.
store.setCursor(afterTime, afterId);
} else if (acked.isNotEmpty) {
await deleteOp(opened.session, acked); await deleteOp(opened.session, acked);
} }
}
} finally { } finally {
opened.session.wire.close(); opened.session.wire.close();
} }
if (!leaveOnServer) {
// Everything acknowledged is deleted, so the next fetch starts fresh; a
// record left on the server (rejected above) simply resurfaces then.
store.setCursor(0, Uint8List(idLen));
}
return FetchSummary(stored, rejected); return FetchSummary(stored, rejected);
} }
// --- delete ------------------------------------------------------------------
/// Deletes a message locally, and from the server too if it might still be
/// sitting there (only possible when "leave mail on server" was on when it
/// was fetched — §10). Sent copies are local-only; there is nothing
/// server-side to remove for them (§5.6). Throws, leaving the local copy in
/// place, if a needed server-side delete fails — otherwise a message could
/// look gone locally while silently persisting on the server.
Future<void> deleteMessage(String folder, MailRecord record) async {
if (folder != "sent" && record.keptOnServer) {
final me = identity;
final addr = accountAddress();
if (me == null || addr == null) {
throw const SmolError(
"not registered; cannot reach the server to delete this message");
}
final opened = await connect(addr, requirePin: true);
try {
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
sync: 0, tokens: const []);
await deleteOp(opened.session, [unhex(record.id)]);
} finally {
opened.session.wire.close();
}
}
await store.deleteMessage(folder, record.id);
}
// §5.8: an Accept field is bound to the signer of the message that carried
// it, which unseal() has already verified.
void _learnToken(OpenedMessage msg) {
final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true));
final raw = parsed.fields["accept"];
if (raw == null) return;
Uint8List token;
try {
token = b32decode(raw);
} on SmolError {
return;
}
if (token.length != tokenLen) return;
final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]);
if (address == null) return; // no address to send to, so no use for a token
store.learnToken(address, token);
}
/// The address we know a signer by: a contact, or the Reply-To it signed
/// for itself. Naming a mailbox is not trusting a key, so nothing is
/// pinned here (§5.7, §8).
String? _addressOfSigner(Uint8List sender, String? replyTo) {
final known = store.addressForKey(sender);
if (known != null) return known;
if (replyTo == null) return null;
try {
final parsed = parseAddress(replyTo);
if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) {
return parsed.short;
}
} on SmolError {
// malformed claim: no address to learn a token under
}
return null;
}
// --- accept tokens (§5.8) --------------------------------------------------------
/// Admit a contact to the main tier; their token travels in our next
/// message to them. Pushes the change to the server right away, since an
/// accept or a block only takes effect once it holds the changed set.
Future<int> acceptContact(String address) async {
final key = store.contact(address)?.key;
if (key == null) throw SmolError("no key for $address yet");
store.accept(address, key);
store.setSyncOk(true);
return _pushTokens();
}
/// Withdraw a contact's accept token; their mail lands in requests from
/// their next message on.
Future<int> blockContact(String address) async {
store.block(address);
return _pushTokens();
}
Future<int> _pushTokens() async {
final me = identity;
final master = this.master;
final addr = accountAddress();
if (me == null || master == null) throw const SmolError("no identity yet");
if (addr == null) {
_warn("not registered; the set will be pushed with your first fetch");
return 0;
}
final opened = await connect(addr, requirePin: true);
try {
final (sync, tokens) = store.tokenSet(master);
return await authenticate(opened.session, opened.handshakeHash, addr.user, me,
sync: sync, tokens: tokens);
} finally {
opened.session.wire.close();
}
}
// --- compose and send ----------------------------------------------------------- // --- compose and send -----------------------------------------------------------
// Prefer a key we already trust; fall back to RESOLVE with trust on first // Prefer a key we already trust; fall back to RESOLVE with trust on first
@ -227,7 +387,8 @@ class SmolClient {
Future<String> send(String toText, String subject, String body, Future<String> send(String toText, String subject, String body,
{String? replyTo, bool anonymous = false}) async { {String? replyTo, bool anonymous = false}) async {
final me = identity; final me = identity;
if (me == null) throw const SmolError("no identity yet"); final master = this.master;
if (me == null || master == null) throw const SmolError("no identity yet");
final addr = parseAddress(toText); final addr = parseAddress(toText);
final recipient = await resolveRecipient(addr); final recipient = await resolveRecipient(addr);
final account = accountAddress(); final account = accountAddress();
@ -237,12 +398,21 @@ class SmolClient {
if (account != null && !anonymous) { if (account != null && !anonymous) {
fields["Reply-To"] = account.uri(me.publicKey); fields["Reply-To"] = account.uri(me.publicKey);
} }
// §5.8: hand an accepted correspondent the token for our own mailbox, so
// a first reply from them reaches our main tier.
final accepted = store.accepted(addr.short);
if (accepted != null && accepted.active) {
fields["Accept"] = b32encode(tokenFor(master, accepted.identity));
}
final bodyBytes = utf8Bytes(buildFrontmatter( final bodyBytes = utf8Bytes(buildFrontmatter(
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
final envelope = seal(me, recipient, bodyBytes); final envelope = seal(me, recipient, bodyBytes);
// §5.8: our token for their mailbox, if they have given us one.
final held = store.tokenFrom(addr.short);
final mac = held == null ? null : acceptMac(held, messageId(envelope));
final opened = await connect(addr, requirePin: false); final opened = await connect(addr, requirePin: false);
try { try {
await sendOp(opened.session, envelope); await sendOp(opened.session, envelope, mac: mac);
} finally { } finally {
opened.session.wire.close(); opened.session.wire.close();
} }
@ -293,7 +463,7 @@ class SmolClient {
/// full smol:// URI whose key matches the signer (§5.7); anything else is /// full smol:// URI whose key matches the signer (§5.7); anything else is
/// ordinary text. /// ordinary text.
SmolAddress? replyAddress(OpenedRecord opened) { SmolAddress? replyAddress(OpenedRecord opened) {
final claim = opened.fields["Reply-To"]; final claim = opened.fields["reply-to"];
if (claim == null || opened.sender == null) return null; if (claim == null || opened.sender == null) return null;
try { try {
final parsed = parseAddress(claim); final parsed = parseAddress(claim);
@ -370,7 +540,7 @@ class SmolClient {
if (timingSafeEqual(known.key, resolved.identity)) { if (timingSafeEqual(known.key, resolved.identity)) {
return RefreshOutcome("${addr.short}: key unchanged", false); return RefreshOutcome("${addr.short}: key unchanged", false);
} }
if (walkChain(known.key, resolved.identity, resolved.chain)) { if (walkChain(addr.user, known.key, resolved.identity, resolved.chain)) {
store.saveContact(addr.short, resolved.identity, known.verified); store.saveContact(addr.short, resolved.identity, known.verified);
return RefreshOutcome( return RefreshOutcome(
"${addr.short} rotated its key; a signed chain confirms it.\n" "${addr.short} rotated its key; a signed chain confirms it.\n"
@ -395,25 +565,27 @@ class SmolClient {
// --- rotation ----------------------------------------------------------------- // --- rotation -----------------------------------------------------------------
// §7: rotate to a fresh seed and rebind the account with a signed // §7: rotate to the next index's derived key and rebind the account with a
// certificate. The old seed is kept by the store, since mail sealed to it // signed certificate. The superseded key stays derivable from the master,
// stays readable with nothing else. // since mail sealed to it stays readable with nothing else.
Future<SmolIdentity> rotateIdentity() async { Future<SmolIdentity> rotateIdentity() async {
final me = identity; final me = identity;
final master = this.master;
final addr = accountAddress(); final addr = accountAddress();
if (me == null || addr == null) { if (me == null || master == null || addr == null) {
throw const SmolError("rotate needs a registered account"); throw const SmolError("rotate needs a registered account");
} }
final fresh = newIdentity(); final freshSeed = identitySeed(master, store.rotations() + 1);
final cert = makeCert(me, fresh.seed); final fresh = identityFromSeed(freshSeed);
final cert = makeCert(addr.user, me, freshSeed);
final opened = await connect(addr, requirePin: true); final opened = await connect(addr, requirePin: true);
try { try {
await registerOp(opened.session, addr.user, fresh, await registerOp(opened.session, opened.serverStatic, addr.user, fresh,
RegisterOptions(cert: cert)); RegisterOptions(cert: cert));
} finally { } finally {
opened.session.wire.close(); opened.session.wire.close();
} }
store.rotateIdentity(fresh.seed); store.advanceRotation();
_openedCache.clear(); _openedCache.clear();
return fresh; return fresh;
} }

View file

@ -1,6 +1,6 @@
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and // Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed
// signed envelopes, body frontmatter, key rotation, and the framed request // and signed envelopes, body frontmatter, key rotation, accept tokens, and
// and response bodies of the five operations. // the framed request and response bodies of the five operations.
import "dart:math"; import "dart:math";
import "dart:typed_data"; import "dart:typed_data";
@ -10,9 +10,11 @@ import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/noise.dart"; import "package:smol_mail/smol/noise.dart";
const defaultPort = 1961; const defaultPort = 1961;
const keyLen = 32, sigLen = 64, certLen = 136, idLen = 16; const keyLen = 32, sigLen = 64, certLen = 200, idLen = 32, tokenLen = 32;
const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024; const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024;
const envelopeHeader = 69, payloadHeader = 45, maxChain = 16; const envelopeHeader = 69, payloadHeader = 45, maxChain = 16;
const maxSkew = 86400; // §5.3: how far ahead of our clock a payload may be dated
const flagRequests = 0x01; // §6.1: set when a FETCH record missed an accept token
const _frontmatterMax = 4096, _frontmatterKeys = 64; const _frontmatterMax = 4096, _frontmatterKeys = 64;
const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03, const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03,
@ -32,6 +34,10 @@ final _label = (
msg: utf8Bytes("smolmail/1 msg"), msg: utf8Bytes("smolmail/1 msg"),
id: utf8Bytes("smolmail/1 id"), id: utf8Bytes("smolmail/1 id"),
rotate: utf8Bytes("smolmail/1 rotate"), rotate: utf8Bytes("smolmail/1 rotate"),
identity: utf8Bytes("smolmail/1 identity"),
accept: utf8Bytes("smolmail/1 accept"),
mac: utf8Bytes("smolmail/1 mac"),
register: utf8Bytes("smolmail/1 register"),
); );
// --- encoding helpers --------------------------------------------------------- // --- encoding helpers ---------------------------------------------------------
@ -139,11 +145,44 @@ SmolIdentity identityFromSeed(Uint8List seed) {
SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen)); SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen));
// §2: the only secret a user holds. Everything else — every rotation index's
// signing seed, and the accept key — is derived from it with HKDF.
Uint8List identitySeed(Uint8List master, int index) =>
hkdfSha256(master, Uint8List(0), concat([_label.identity, u32be(index)]));
Uint8List acceptKeyFor(Uint8List master) =>
hkdfSha256(master, Uint8List(0), _label.accept);
// §5.8: the token this account issues to one correspondent, independent of
// the rotation index so it survives the owner's key rotation.
Uint8List tokenFor(Uint8List master, Uint8List correspondentIdentity) =>
hmacSha256(acceptKeyFor(master), correspondentIdentity);
// §5.8: what a sender attaches to SEND to reach the recipient's main tier.
Uint8List acceptMac(Uint8List token, Uint8List id) =>
hmacSha256(token, concat([_label.mac, id]));
// --- addressing (§3) ----------------------------------------------------------- // --- addressing (§3) -----------------------------------------------------------
final _address = final _address =
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$"); RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$");
const _separators = "._-";
// §3: alphanumeric at both ends, never two separators in a row.
bool validUsername(String name) {
if (name.isEmpty) return false;
if (_separators.contains(name[0]) || _separators.contains(name[name.length - 1])) {
return false;
}
for (var i = 0; i < name.length - 1; i++) {
if (_separators.contains(name[i]) && _separators.contains(name[i + 1])) {
return false;
}
}
return true;
}
class SmolAddress { class SmolAddress {
final String user; final String user;
final String host; final String host;
@ -179,8 +218,9 @@ SmolAddress parseAddress(String text) {
if (m == null) throw SmolError("'$text' is not a valid address"); if (m == null) throw SmolError("'$text' is not a valid address");
final user = m.namedGroup("user")!; final user = m.namedGroup("user")!;
final host = m.namedGroup("host")!; final host = m.namedGroup("host")!;
if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) { if (!validUsername(user)) {
throw SmolError("$user may not begin or end with a separator"); throw SmolError("$user must begin and end with a letter or digit "
"and may not contain two separators in a row");
} }
final portText = m.namedGroup("port"); final portText = m.namedGroup("port");
final port = portText != null ? int.parse(portText) : defaultPort; final port = portText != null ? int.parse(portText) : defaultPort;
@ -189,8 +229,9 @@ SmolAddress parseAddress(String text) {
// --- message format (§5) ------------------------------------------------------- // --- message format (§5) -------------------------------------------------------
Uint8List messageId(List<int> envelope) => // §5.4: derived from the envelope so no sender can choose it; used whole,
sha256(concat([_label.id, envelope])).sublist(0, idLen); // nothing truncates it.
Uint8List messageId(List<int> envelope) => sha256(concat([_label.id, envelope]));
class OpenedMessage { class OpenedMessage {
final Uint8List sender; final Uint8List sender;
@ -288,6 +329,9 @@ OpenedMessage unseal(List<SmolIdentity> identities, Uint8List envelope) {
signature)) { signature)) {
throw const SmolError("signature does not verify"); throw const SmolError("signature does not verify");
} }
if (when > nowSeconds() + maxSkew) {
throw const SmolError("payload is dated in the future");
}
return OpenedMessage(sender, when, body, messageId(envelope)); return OpenedMessage(sender, when, body, messageId(envelope));
} }
@ -304,7 +348,8 @@ class Frontmatter {
// A flat `Key: value` block, deliberately not YAML. Any malformed line // A flat `Key: value` block, deliberately not YAML. Any malformed line
// invalidates the whole block, which is then returned as ordinary body text: // invalidates the whole block, which is then returned as ordinary body text:
// frontmatter fails closed toward display, never toward silent discard. // frontmatter fails closed toward display, never toward silent discard. Keys
// are compared case-insensitively (§5.5), so they are kept lowercased.
Frontmatter parseFrontmatter(String text) { Frontmatter parseFrontmatter(String text) {
if (!text.startsWith("---\n")) return Frontmatter(const {}, text); if (!text.startsWith("---\n")) return Frontmatter(const {}, text);
final lines = text.split("\n"); final lines = text.split("\n");
@ -327,7 +372,7 @@ Frontmatter parseFrontmatter(String text) {
return Frontmatter(const {}, text); return Frontmatter(const {}, text);
} }
// first occurrence wins // first occurrence wins
fields.putIfAbsent(head, () => line.substring(colon + 1).trim()); fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim());
} }
return Frontmatter(fields, rest); return Frontmatter(fields, rest);
} }
@ -343,35 +388,45 @@ String buildFrontmatter(Map<String, String> fields, String body) {
// --- key rotation (§7) --------------------------------------------------------- // --- key rotation (§7) ---------------------------------------------------------
Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { // §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both
final newPub = ed25519PublicKey(newSeed); // keys sign, so the old key alone cannot hand the username to a key nobody
// controls; the username is covered but not carried, so a verifier always
// supplies the one it is checking.
Uint8List makeCert(
String username, SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
final newIdentity = identityFromSeed(newSeed);
final time = i64be(BigInt.from(when ?? nowSeconds())); final time = i64be(BigInt.from(when ?? nowSeconds()));
final signed = concat(
[_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]);
return concat([ return concat([
oldIdentity.publicKey, oldIdentity.publicKey,
newPub, newIdentity.publicKey,
time, time,
ed25519Sign(oldIdentity.seed, ed25519Sign(oldIdentity.seed, signed),
concat([_label.rotate, oldIdentity.publicKey, newPub, time])), ed25519Sign(newIdentity.seed, signed),
]); ]);
} }
// Accept a key change only when a signed chain leads from the key we hold to // Accept a key change only when a signed chain leads from the key we hold to
// the one the server now returns (§7). // the one the server now returns, both keys signing each link (§7).
bool walkChain(Uint8List pinned, Uint8List current, List<Uint8List> chain) { bool walkChain(
String username, Uint8List pinned, Uint8List current, List<Uint8List> chain) {
if (timingSafeEqual(pinned, current)) return true; if (timingSafeEqual(pinned, current)) return true;
if (chain.isEmpty || chain.length > maxChain) return false; if (chain.isEmpty || chain.length > maxChain) return false;
var key = pinned; var key = pinned;
var started = false; var started = false;
for (final cert in chain) { for (final cert in chain) {
final old = cert.sublist(0, 32), next = cert.sublist(32, 64); final old = cert.sublist(0, 32), next = cert.sublist(32, 64);
final when = cert.sublist(64, 72), sig = cert.sublist(72); final when = cert.sublist(64, 72);
final sigOld = cert.sublist(72, 136), sigNew = cert.sublist(136, 200);
if (!started) { if (!started) {
if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold
started = true; started = true;
} else if (!timingSafeEqual(old, key)) { } else if (!timingSafeEqual(old, key)) {
return false; // the chain is not continuous return false; // the chain is not continuous
} }
if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) { final signed = concat([_label.rotate, utf8Bytes(username), old, next, when]);
if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) {
return false; return false;
} }
key = next; key = next;
@ -482,18 +537,27 @@ void expectOk(int status, String what) {
} }
// §4 session authentication: sign the handshake hash, which binds the // §4 session authentication: sign the handshake hash, which binds the
// signature to this session's server ephemeral and cannot be replayed. // signature to this session's server ephemeral and cannot be replayed, and
Future<void> authenticate( // push the accept token set (§5.8). `sync = 0` leaves the server's stored set
Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async { // untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly.
// Returns the number of accept tokens the server now holds.
Future<int> authenticate(Session session, Uint8List handshakeHash, String username,
SmolIdentity identity, {required int sync, List<Uint8List> tokens = const []}) async {
final name = utf8Bytes(username); final name = utf8Bytes(username);
if (name.length > 255) throw const SmolError("username too long"); if (name.length > 255) throw const SmolError("username too long");
if (tokens.length > 0xffff) throw const SmolError("too many accept tokens for one AUTH");
final body = concat([ final body = concat([
Uint8List.fromList([name.length]), Uint8List.fromList([name.length]),
name, name,
identity.publicKey, identity.publicKey,
ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])), ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])),
Uint8List.fromList([sync]),
u16be(tokens.length),
...tokens,
]); ]);
expectOk((await session.call(opAuth, body)).status, "authentication"); final response = await session.call(opAuth, body);
expectOk(response.status, "authentication");
return Reader(response.body).u16();
} }
class Resolved { class Resolved {
@ -516,8 +580,14 @@ Future<Resolved> resolveOp(Session session, String user) async {
List.generate(r.u8(), (_) => r.take(certLen))); List.generate(r.u8(), (_) => r.take(certLen)));
} }
Future<Uint8List> sendOp(Session session, Uint8List envelope) async { // §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes.
final response = await session.call(opSend, envelope); Future<Uint8List> sendOp(Session session, Uint8List envelope, {Uint8List? mac}) async {
final macBytes = mac ?? Uint8List(0);
if (macBytes.isNotEmpty && macBytes.length != tokenLen) {
throw const SmolError("accept MAC must be $tokenLen bytes");
}
final body = concat([Uint8List.fromList([macBytes.length]), macBytes, envelope]);
final response = await session.call(opSend, body);
expectOk(response.status, "sending"); expectOk(response.status, "sending");
return response.body.length == idLen return response.body.length == idLen
? response.body ? response.body
@ -527,19 +597,27 @@ Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
class FetchedRecord { class FetchedRecord {
final Uint8List id; final Uint8List id;
final int receivedAt; final int receivedAt;
final int flags;
final Uint8List envelope; final Uint8List envelope;
const FetchedRecord(this.id, this.receivedAt, this.envelope); const FetchedRecord(this.id, this.receivedAt, this.flags, this.envelope);
// §6.1: bit 0 is set when the message arrived without a matching accept token.
bool get isRequest => flags & flagRequests != 0;
} }
Future<List<FetchedRecord>> fetchOp(Session session) async { // §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
final response = await session.call(opFetch); Future<List<FetchedRecord>> fetchOp(
Session session, int afterReceivedAt, Uint8List afterId) async {
final body = concat([i64be(BigInt.from(afterReceivedAt)), afterId]);
final response = await session.call(opFetch, body);
expectOk(response.status, "fetching"); expectOk(response.status, "fetching");
final r = Reader(response.body); final r = Reader(response.body);
return List.generate(r.u16(), (_) { return List.generate(r.u16(), (_) {
final id = r.take(idLen); final id = r.take(idLen);
final receivedAt = r.i64(); final receivedAt = r.i64();
return FetchedRecord(id, receivedAt, r.take(r.u32())); final flags = r.u8();
return FetchedRecord(id, receivedAt, flags, r.take(r.u32()));
}); });
} }
@ -558,9 +636,13 @@ class RegisterOptions {
const RegisterOptions({this.token = "", this.cert}); const RegisterOptions({this.token = "", this.cert});
} }
Future<void> registerOp( // §6.1: the signature is proof of possession, bound to the server that will
Session session, String username, SmolIdentity identity, // store the binding so it cannot be replayed to another server.
[RegisterOptions opts = const RegisterOptions()]) async { Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) =>
concat([_label.register, serverStatic, utf8Bytes(username), identity]);
Future<void> registerOp(Session session, Uint8List serverStatic, String username,
SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async {
final name = utf8Bytes(username); final name = utf8Bytes(username);
final tokenBytes = utf8Bytes(opts.token); final tokenBytes = utf8Bytes(opts.token);
final cert = opts.cert ?? Uint8List(0); final cert = opts.cert ?? Uint8List(0);
@ -571,6 +653,7 @@ Future<void> registerOp(
Uint8List.fromList([name.length]), Uint8List.fromList([name.length]),
name, name,
identity.publicKey, identity.publicKey,
ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)),
Uint8List.fromList([tokenBytes.length]), Uint8List.fromList([tokenBytes.length]),
tokenBytes, tokenBytes,
Uint8List.fromList([cert.length]), Uint8List.fromList([cert.length]),

View file

@ -1,7 +1,7 @@
// Device state: identity, pins, contacts and read markers in one JSON blob; // Device state: identity, pins, contacts and read markers in one JSON blob;
// sealed envelopes in a second Hive box, opened only on demand, so nothing at // sealed envelopes in a second Hive box, opened only on demand, so nothing at
// rest is plaintext (the seed excepted — the device's app storage is the trust // rest is plaintext (the master secret excepted — the device's app storage is
// boundary, like gsmol's browser profile). // the trust boundary, like gsmol's browser profile).
import "dart:convert"; import "dart:convert";
import "dart:typed_data"; import "dart:typed_data";
@ -42,14 +42,36 @@ class StoredContact {
} }
class MailRecord { class MailRecord {
final String id; // hex of the 16-byte message id final String id; // hex of the 32-byte message id
final Uint8List envelope; final Uint8List envelope;
final int? receivedAt; final int? receivedAt;
final String? recipient; // sent copies only final String? recipient; // sent copies only
final int? sentAt; final int? sentAt;
final int tier; // §5.8: tierMain or tierRequests; meaningless for sent copies
/// Whether "leave mail on server" was on when this was fetched, so a
/// manual delete still has a server-side copy to remove. Always false for
/// sent copies, which never had one (§5.6).
final bool keptOnServer;
const MailRecord(this.id, this.envelope, const MailRecord(this.id, this.envelope,
{this.receivedAt, this.recipient, this.sentAt}); {this.receivedAt,
this.recipient,
this.sentAt,
this.tier = tierMain,
this.keptOnServer = false});
}
const tierMain = 0, tierRequests = 1;
/// A correspondent admitted to this mailbox's main tier (§5.8). The identity
/// is frozen at acceptance because the token is derived from it: a contact's
/// later rotation must not change the token they already hold.
class AcceptedContact {
final Uint8List identity;
final bool active;
const AcceptedContact(this.identity, this.active);
} }
class ImportSummary { class ImportSummary {
@ -87,49 +109,66 @@ class SmolStore {
_state.put(_stateKey, next); _state.put(_stateKey, next);
} }
// --- identity -------------------------------------------------------------- // --- identity (§2) -----------------------------------------------------------
Uint8List? seed() { /// The 32-byte master secret, or null before the user creates or restores
final raw = _load()["seed"]; /// one. Every signing key is derived from it plus the rotation index.
Uint8List? master() {
final raw = _load()["master"];
return raw == null ? null : unhex(raw as String); return raw == null ? null : unhex(raw as String);
} }
/// The rotation index (§7) of the identity currently in use.
int rotations() => (_load()["rotations"] as int?) ?? 0;
/// The active identity, or null before the user creates or restores one. /// The active identity, or null before the user creates or restores one.
SmolIdentity? identity() { SmolIdentity? identity() {
final s = seed(); final m = master();
return s == null ? null : identityFromSeed(s); return m == null ? null : identityFromSeed(identitySeed(m, rotations()));
} }
void setIdentity(Uint8List newSeed) { /// Whether the accepted-correspondent set held here may replace the
if (seed() != null) { /// server's on the next AUTH — false right after a restore from the master
throw const SmolIdentityExistsException(); /// alone, whose empty set must not erase the server's (§4).
} bool syncOk() => (_load()["syncOk"] as bool?) ?? true;
_update((state) => state..["seed"] = hex(newSeed));
void setSyncOk(bool ok) => _update((state) => state..["syncOk"] = ok);
void _bindMaster(Uint8List newMaster, int rotations, bool syncOk) {
if (master() != null) throw const SmolIdentityExistsException();
_update((state) => state
..["master"] = hex(newMaster)
..["rotations"] = rotations
..["syncOk"] = syncOk);
setCursor(0, Uint8List(idLen));
} }
// Rotation (§7): the old seed is retained, since mail sealed to a /// A fresh identity: rotation index 0, and an empty accepted set is
// superseded key is readable with nothing else. /// already complete, so it may sync.
void rotateIdentity(Uint8List newSeed) { void setMaster(Uint8List newMaster) => _bindMaster(newMaster, 0, true);
final old = seed();
if (old == null) throw const SmolNoIdentityException(); /// §2: recovering a master alone does not recover which correspondents were
_update((state) { /// accepted, so that set must not overwrite the server's until rebuilt.
final retired = (state["retired"] as List? ?? []) void restoreMaster(Uint8List newMaster, int rotationIndex) =>
..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch}); _bindMaster(newMaster, rotationIndex, false);
state["retired"] = retired;
state["seed"] = hex(newSeed); // Rotation (§7): only the index advances; the superseded key stays
return state; // derivable from the master, so nothing has to be archived.
}); void advanceRotation() {
final current = rotations();
if (master() == null) throw const SmolNoIdentityException();
if (current >= maxChain) {
throw SmolError("the rotation chain is full at $maxChain links");
}
_update((state) => state..["rotations"] = current + 1);
} }
/// §7: seeds rotated away from are retained, since mail sealed to a /// §7: every key rotated away from is re-derivable from the master, since
/// superseded key is readable with nothing else. /// mail sealed to a superseded key is readable with nothing else.
List<SmolIdentity> identities() { List<SmolIdentity> identities() {
final s = seed(); final m = master();
if (s == null) return const []; if (m == null) return const [];
final retired = (_load()["retired"] as List? ?? const []) return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))];
.whereType<Map>()
.map((entry) => identityFromSeed(unhex(entry["seed"] as String)));
return [identityFromSeed(s), ...retired];
} }
// --- account and server pins ------------------------------------------------- // --- account and server pins -------------------------------------------------
@ -176,6 +215,31 @@ class SmolStore {
return [for (final e in servers.entries) (e.key, b32decode(e.value))]; return [for (final e in servers.entries) (e.key, b32decode(e.value))];
} }
// --- FETCH behavior --------------------------------------------------------
/// When true, FETCH does not acknowledge (delete) what it retrieves —
/// mail stays on the server until explicitly deleted. Defaults to the
/// original behavior: fetched mail is acknowledged immediately.
bool leaveOnServer() => (_load()["leaveOnServer"] as bool?) ?? false;
void setLeaveOnServer(bool value) =>
_update((state) => state..["leaveOnServer"] = value);
// --- FETCH cursor (§6.1) -------------------------------------------------------
(int, Uint8List) cursor() {
final state = _load();
final afterId = state["afterId"] as String?;
return (
(state["afterTime"] as int?) ?? 0,
afterId == null ? Uint8List(idLen) : unhex(afterId),
);
}
void setCursor(int afterTime, Uint8List afterId) => _update((state) => state
..["afterTime"] = afterTime
..["afterId"] = hex(afterId));
// --- contacts ------------------------------------------------------------------ // --- contacts ------------------------------------------------------------------
StoredContact? contact(String address) { StoredContact? contact(String address) {
@ -233,6 +297,82 @@ class SmolStore {
return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)]; return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)];
} }
// --- accept tokens (§5.8) --------------------------------------------------------
AcceptedContact? accepted(String address) {
final a = ((_load()["accepted"] as Map?) ?? {})[address];
if (a is! Map) return null;
return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool);
}
/// Admit a contact to the main tier. The identity is frozen at acceptance —
/// re-accepting after a block must not change which key the token is
/// derived from (§5.8).
void accept(String address, Uint8List identity) {
_update((state) {
final accepted = (state["accepted"] as Map? ?? {}).cast<String, Map>();
final previous = accepted[address];
accepted[address] = {
"identity": previous?["identity"] ?? b32encode(identity),
"active": true,
"addedAt": previous?["addedAt"] ?? DateTime.now().millisecondsSinceEpoch,
};
state["accepted"] = accepted;
return state;
});
}
/// Withdraw a contact's accept token; their mail lands in the requests tier
/// from their next message on. Throws if the contact was never accepted.
void block(String address) {
final accepted = (_load()["accepted"] as Map? ?? {}).cast<String, Map>();
if (!accepted.containsKey(address)) {
throw SmolError("$address was never accepted");
}
_update((state) {
final accepted = (state["accepted"] as Map? ?? {}).cast<String, Map>();
accepted[address] = {...accepted[address]!, "active": false};
state["accepted"] = accepted;
return state;
});
}
List<(String, AcceptedContact)> allAccepted() {
final accepted = ((_load()["accepted"] as Map?) ?? {}).cast<String, Map>();
return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)];
}
/// §4: the tokens to push with AUTH, and whether to push at all. A client
/// that cannot vouch for its own set — one restored from the master alone —
/// must not replace the server's with an incomplete one.
(int, List<Uint8List>) tokenSet(Uint8List master) {
if (!syncOk()) return (0, const []);
final active = allAccepted().where((e) => e.$2.active).toList()
..sort((a, b) => a.$1.compareTo(b.$1));
return (1, [for (final e in active) tokenFor(master, e.$2.identity)]);
}
/// A token received from a correspondent, filed under the address that
/// issued it: an address outlives the keys behind it, so the token keeps
/// working across the issuer's rotations (§5.8).
Uint8List? tokenFrom(String address) {
final raw = ((_load()["tokens"] as Map?) ?? {})[address];
if (raw is! Map) return null;
return b32decode(raw["token"] as String);
}
void learnToken(String address, Uint8List token) {
_update((state) {
final tokens = (state["tokens"] as Map? ?? {}).cast<String, Map>();
tokens[address] = {
"token": b32encode(token),
"seenAt": DateTime.now().millisecondsSinceEpoch,
};
state["tokens"] = tokens;
return state;
});
}
// --- read markers --------------------------------------------------------------- // --- read markers ---------------------------------------------------------------
void markRead(String idHex) { void markRead(String idHex) {
@ -255,6 +395,8 @@ class SmolStore {
"receivedAt": record.receivedAt, "receivedAt": record.receivedAt,
"recipient": record.recipient, "recipient": record.recipient,
"sentAt": record.sentAt, "sentAt": record.sentAt,
"tier": record.tier,
"keptOnServer": record.keptOnServer,
}; };
MailRecord _mapToRecord(Map map) => MailRecord( MailRecord _mapToRecord(Map map) => MailRecord(
@ -263,10 +405,18 @@ class SmolStore {
receivedAt: map["receivedAt"] as int?, receivedAt: map["receivedAt"] as int?,
recipient: map["recipient"] as String?, recipient: map["recipient"] as String?,
sentAt: map["sentAt"] as int?, sentAt: map["sentAt"] as int?,
tier: (map["tier"] as int?) ?? tierMain,
keptOnServer: (map["keptOnServer"] as bool?) ?? false,
); );
static String mailKey(String folder, String id) => "$folder/$id"; static String mailKey(String folder, String id) => "$folder/$id";
// "requests" is a view over the same physical "inbox" records, filtered by
// tier (§5.8) — not a separate folder, so a message keeps one identity
// regardless of which tier it arrived in.
static String _physicalFolder(String folder) =>
folder == "requests" ? "inbox" : folder;
Future<void> storeMessage(String folder, MailRecord record) => Future<void> storeMessage(String folder, MailRecord record) =>
_mail.put(mailKey(folder, record.id), _recordToMap(record)); _mail.put(mailKey(folder, record.id), _recordToMap(record));
@ -279,12 +429,17 @@ class SmolStore {
} }
List<MailRecord> listMessages(String folder) { List<MailRecord> listMessages(String folder) {
final prefix = "$folder/"; final physical = _physicalFolder(folder);
final prefix = "$physical/";
final wantTier = folder == "requests" ? tierRequests : tierMain;
final rows = <MailRecord>[]; final rows = <MailRecord>[];
for (final key in _mail.keys.cast<String>()) { for (final key in _mail.keys.cast<String>()) {
if (!key.startsWith(prefix)) continue; if (!key.startsWith(prefix)) continue;
final row = _mail.get(key); final row = _mail.get(key);
if (row is Map) rows.add(_mapToRecord(row)); if (row is! Map) continue;
final record = _mapToRecord(row);
if (physical == "inbox" && record.tier != wantTier) continue;
rows.add(record);
} }
rows.sort((a, b) => rows.sort((a, b) =>
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0)); (b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
@ -292,29 +447,29 @@ class SmolStore {
} }
MailRecord? getMessage(String folder, String id) { MailRecord? getMessage(String folder, String id) {
final row = _mail.get(mailKey(folder, id)); final row = _mail.get(mailKey(_physicalFolder(folder), id));
return row is Map ? _mapToRecord(row) : null; return row is Map ? _mapToRecord(row) : null;
} }
Future<void> deleteMessage(String folder, String id) => Future<void> deleteMessage(String folder, String id) =>
_mail.delete(mailKey(folder, id)); _mail.delete(mailKey(_physicalFolder(folder), id));
// --- export / import: mail, contacts, pins — never the seed -------------------- // --- export / import: mail, contacts, pins — never the seed --------------------
/// Label kept as gsmol wrote it originally; the export format version /// Label kept as gsmol wrote it originally; the export format version
/// (gsmolExport) is what actually changed between v1 and v2. /// (gsmolExport) is what actually changed between v1 and v2.
static final _exportLabel = utf8Bytes("gsmol/1 export"); static final _exportLabel = utf8Bytes("gsmol/1 export");
Uint8List _exportKey(Uint8List seed) => Uint8List _exportKey(Uint8List master) =>
hkdfSha256(seed, Uint8List(0), _exportLabel, 32); hkdfSha256(master, Uint8List(0), _exportLabel, 32);
/// v2 matches gsmol's own current export: the whole payload — mail, /// v2 matches gsmol's own current export: the whole payload — mail,
/// contacts, pins — is sealed to a key derived from the identity's seed, so /// contacts, pins — is sealed to a key derived from the identity's master,
/// a backup file is only readable by whoever holds that seed. Deliberately /// so a backup file is only readable by whoever holds that master.
/// excludes the seed itself: it has its own reveal-and-copy flow in /// Deliberately excludes the master itself: it has its own reveal-and-copy
/// settings, meant for a password manager, not a shareable file. /// flow in settings, meant for a password manager, not a shareable file.
Map<String, dynamic> exportData() { Map<String, dynamic> exportData() {
final seed = this.seed(); final master = this.master();
if (seed == null) throw const SmolError("no identity yet"); if (master == null) throw const SmolError("no identity yet");
final state = _load(); final state = _load();
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>(); final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
final payload = { final payload = {
@ -329,11 +484,13 @@ class SmolStore {
} }
}, },
"inbox": [ "inbox": [
for (final row in listMessages("inbox")) for (final row in [...listMessages("inbox"), ...listMessages("requests")])
{ {
"id": row.id, "id": row.id,
"receivedAt": row.receivedAt, "receivedAt": row.receivedAt,
"envelope": base64Encode(row.envelope), "envelope": base64Encode(row.envelope),
"tier": row.tier,
"keptOnServer": row.keptOnServer,
} }
], ],
"sent": [ "sent": [
@ -348,7 +505,7 @@ class SmolStore {
}; };
final nonce = randomBytes(12); final nonce = randomBytes(12);
final ciphertext = aeadEncrypt( final ciphertext = aeadEncrypt(
_exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); _exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0));
return { return {
"gsmolExport": 2, "gsmolExport": 2,
"exportedAt": DateTime.now().millisecondsSinceEpoch, "exportedAt": DateTime.now().millisecondsSinceEpoch,
@ -364,13 +521,13 @@ class SmolStore {
Future<ImportSummary> importData(Map data) async { Future<ImportSummary> importData(Map data) async {
Map payload; Map payload;
if (data["gsmolExport"] == 2) { if (data["gsmolExport"] == 2) {
final seed = this.seed(); final master = this.master();
if (seed == null) { if (master == null) {
throw const SmolError("no identity yet — restore it before importing"); throw const SmolError("no identity yet — restore it before importing");
} }
try { try {
final plaintext = aeadDecrypt( final plaintext = aeadDecrypt(
_exportKey(seed), _exportKey(master),
base64Decode(data["nonce"] as String), base64Decode(data["nonce"] as String),
base64Decode(data["ciphertext"] as String), base64Decode(data["ciphertext"] as String),
Uint8List(0), Uint8List(0),
@ -447,6 +604,8 @@ class SmolStore {
receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null, receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null,
recipient: folder == "sent" ? map["recipient"] as String? : null, recipient: folder == "sent" ? map["recipient"] as String? : null,
sentAt: folder == "sent" ? map["sentAt"] as int? : null, sentAt: folder == "sent" ? map["sentAt"] as int? : null,
tier: (map["tier"] as int?) ?? tierMain,
keptOnServer: (map["keptOnServer"] as bool?) ?? false,
); );
if (await storeIfNew(folder, record) != null) summary.mailAdded++; if (await storeIfNew(folder, record) != null) summary.mailAdded++;
} on Exception { } on Exception {
@ -481,6 +640,14 @@ class SmolStore {
} }
return count; return count;
} }
int requestsUnreadCount() {
var count = 0;
for (final row in listMessages("requests")) {
if (!isRead(row.id)) count++;
}
return count;
}
} }
/// The store throws these typed errors so the UI can tell "no identity yet" /// The store throws these typed errors so the UI can tell "no identity yet"

View file

@ -1,13 +1,15 @@
// End-to-end against a live reference server: register an address on a // End-to-end against a live reference server: register an address on a
// locally running smolmaild, send a sealed message to ourselves, fetch it back, // locally running smolmaild, send sealed messages to ourselves, fetch them
// and check the server is drained afterwards. Skips when nothing listens on // back, exercise the accept-token round trip (§5.8) between the requests and
// 127.0.0.1:1961, so `devbox run test` does not depend on a server. // main tiers, and check the server is drained afterwards. Skips when nothing
// listens on 127.0.0.1:1961, so `devbox run test` does not depend on a server.
// //
// To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key && // To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key &&
// uv run smolmaild.py serve --key server.key --db mail.db) // uv run smolmaild.py serve --key server.key --db mail.db)
// then `devbox run test`. // then `devbox run test`.
import "dart:io"; import "dart:io";
import "dart:typed_data";
import "package:flutter_test/flutter_test.dart"; import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart"; import "package:hive_flutter/hive_flutter.dart";
@ -47,7 +49,8 @@ void main() {
final warnings = <String>[]; final warnings = <String>[];
client.onWarning = warnings.add; client.onWarning = warnings.add;
final me = client.createIdentity(); final master = client.createIdentity();
final me = client.identity!;
final user = "e2e${hex(randomBytes(4))}"; final user = "e2e${hex(randomBytes(4))}";
final address = parseAddress("$user@$host"); final address = parseAddress("$user@$host");
final learned = await client.connect(address, requirePin: false); final learned = await client.connect(address, requirePin: false);
@ -67,13 +70,16 @@ void main() {
expect(summary.stored, 2); expect(summary.stored, 2);
expect(summary.rejected, isEmpty); expect(summary.rejected, isEmpty);
final inbox = store.listMessages("inbox"); // §5.8: we have not accepted ourselves as a correspondent yet, so this
expect(inbox.length, 2); // unsolicited self-mail lands in the requests tier, not the main one.
expect(store.listMessages("inbox"), isEmpty);
final requests = store.listMessages("requests");
expect(requests.length, 2);
// receivedAt has second granularity, so the order of the two is not // receivedAt has second granularity, so the order of the two is not
// guaranteed; assert on the pair, then open the one we care about. // guaranteed; assert on the pair, then open the one we care about.
final subjects = inbox.map((m) => client.describe(m).subject).toSet(); final subjects = requests.map((m) => client.describe(m).subject).toSet();
expect(subjects, {"hello e2e", "second"}); expect(subjects, {"hello e2e", "second"});
final hello = inbox.firstWhere( final hello = requests.firstWhere(
(m) => client.describe(m).subject == "hello e2e"); (m) => client.describe(m).subject == "hello e2e");
final opened = client.describe(hello); final opened = client.describe(hello);
expect(opened.error, isNull); expect(opened.error, isNull);
@ -84,9 +90,27 @@ void main() {
final again = await client.fetch(); final again = await client.fetch();
expect(again.stored, 0); expect(again.stored, 0);
// Accept ourselves as a correspondent (§5.8): the change is pushed to the
// server right away. This next message carries our own Accept field, but
// no MAC yet — we cannot know our own token before receiving and parsing
// a message that carries it — so it still lands in requests.
await client.acceptContact(address.short);
await client.send(address.short, "third", "still unsolicited");
expect((await client.fetch()).stored, 1);
expect(store.listMessages("requests").length, 3);
expect(store.listMessages("inbox"), isEmpty);
// Having now learned our own token from that message's Accept field, the
// next one carries a matching MAC and reaches the main tier.
await client.send(address.short, "fourth", "now accepted");
expect((await client.fetch()).stored, 1);
final mainTier = store.listMessages("inbox");
expect(mainTier.length, 1);
expect(client.describe(mainTier.single).subject, "fourth");
// The sent copy is sealed to ourselves and readable (§5.6). // The sent copy is sealed to ourselves and readable (§5.6).
final sent = store.listMessages("sent"); final sent = store.listMessages("sent");
expect(sent.length, 2); expect(sent.length, 4);
expect(client.describe(sent.first).error, isNull); expect(client.describe(sent.first).error, isNull);
// A restored seed can rebind the address without REGISTER; the address // A restored seed can rebind the address without REGISTER; the address
@ -96,13 +120,13 @@ void main() {
expect( expect(
() => client.recallAccount("nobody@$host"), throwsA(isA<SmolError>())); () => client.recallAccount("nobody@$host"), throwsA(isA<SmolError>()));
// Restore on a second device: same seed, fresh store, no pin. Unpinned // Restore on a second device: same master, fresh store, no pin. Unpinned
// recall is refused; re-registering a taken name is refused; recall with // recall is refused; re-registering a taken name is refused; recall with
// the operator-supplied key then binds the account without REGISTER. // the operator-supplied key then binds the account without REGISTER.
final restored = await SmolStore.open( final restored = await SmolStore.open(
stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail"); stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail");
final secondDevice = SmolClient(restored); final secondDevice = SmolClient(restored);
restored.setIdentity(me.seed); restored.setMaster(master);
expect( expect(
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>())); secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
restored.pinServer(host, learned.serverStatic); restored.pinServer(host, learned.serverStatic);
@ -118,4 +142,50 @@ void main() {
expect(outcome.message, contains("key unchanged")); expect(outcome.message, contains("key unchanged"));
expect(store.contact(address.short)!.history, isEmpty); expect(store.contact(address.short)!.history, isEmpty);
}, timeout: const Timeout(Duration(minutes: 2))); }, timeout: const Timeout(Duration(minutes: 2)));
test("leave mail on server keeps mail until deleted, with dedupe on refetch",
() async {
if (!await serverUp()) {
markTestSkipped("no smolmaild on $host:$port");
return;
}
final dir = await Directory.systemTemp.createTemp("smol-e2e-keep");
Hive.init(dir.path);
final store =
await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail");
final client = SmolClient(store);
client.createIdentity();
final user = "e2ekeep${hex(randomBytes(4))}";
final address = parseAddress("$user@$host");
final learned = await client.connect(address, requirePin: false);
store.pinServer(host, learned.serverStatic);
learned.session.wire.close();
await client.registerAccount(address.short);
store.setLeaveOnServer(true);
await client.send(address.short, "kept", "stays on the server until deleted");
final first = await client.fetch();
expect(first.stored, 1);
final record = store.listMessages("requests").single;
expect(record.keptOnServer, isTrue);
expect(client.describe(record).subject, "kept");
// Re-fetching from scratch must not duplicate it locally (storeIfNew's
// dedupe), even though the server still has it (nothing was deleted).
store.setCursor(0, Uint8List(idLen));
final second = await client.fetch();
expect(second.stored, 0);
expect(store.listMessages("requests").length, 1);
// Deleting removes it from the server too: a further full re-page after
// deletion must come back empty rather than resurrecting it.
await client.deleteMessage("requests", record);
expect(store.listMessages("requests"), isEmpty);
store.setCursor(0, Uint8List(idLen));
final third = await client.fetch();
expect(third.stored, 0);
expect(store.listMessages("requests"), isEmpty);
}, timeout: const Timeout(Duration(minutes: 2)));
} }

View file

@ -1,4 +1,4 @@
// Regression tests for the onboarding recall flows: restoring a seed and // Regression tests for the onboarding recall flows: restoring a master and
// recalling the registered address must land the user in the inbox. The // recalling the registered address must land the user in the inbox. The
// client's network operations are stubbed; what is under test is the UI and // client's network operations are stubbed; what is under test is the UI and
// router flow itself. // router flow itself.
@ -14,14 +14,28 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart"; import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/smol/client.dart"; import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart"; import "package:smol_mail/smol/store.dart";
/// A [SmolClient] whose recall completes instantly, so the test exercises /// A [SmolClient] whose network operations complete instantly, so the test
/// the flow rather than the network. /// exercises the flow rather than the network. [restoreAndRecall] still
/// enforces the pin gate (SPEC.md §4), since that gate is exactly what the
/// restore flow's UX is regression-tested against.
class StubClient extends SmolClient { class StubClient extends SmolClient {
StubClient(super.store); StubClient(super.store);
@override
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
final addr = parseAddress(addressText);
if (store.serverPin(addr.host) == null) {
throw SmolError("no pinned key for ${addr.host}");
}
store.restoreMaster(unhex(masterHex.trim()), 0);
store.setAccount(addr);
return addr;
}
@override @override
Future<SmolAddress> recallAccount(String addressText) async { Future<SmolAddress> recallAccount(String addressText) async {
final addr = parseAddress(addressText); final addr = parseAddress(addressText);
@ -58,7 +72,7 @@ void main() {
"restore with an address, but no pin yet, asks for the server key " "restore with an address, but no pin yet, asks for the server key "
"and then recalls into the inbox", (tester) async { "and then recalls into the inbox", (tester) async {
final store = storeA; final store = storeA;
final seed = randomBytes(32); final master = randomBytes(32);
await tester.pumpWidget(app(store)); await tester.pumpWidget(app(store));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text("Create Identity"), findsOneWidget); expect(find.text("Create Identity"), findsOneWidget);
@ -68,7 +82,7 @@ void main() {
var fields = find.byType(TextField); var fields = find.byType(TextField);
expect(fields, findsNWidgets(2)); expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(0), hex(seed)); await tester.enterText(fields.at(0), hex(master));
await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore")); await tester.tap(find.text("Restore"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -80,6 +94,9 @@ void main() {
expect(find.text("Pin this server's public key to finish restoring your address."), expect(find.text("Pin this server's public key to finish restoring your address."),
findsOneWidget); findsOneWidget);
expect(find.text("Invite token (optional)"), findsNothing); expect(find.text("Invite token (optional)"), findsNothing);
// Restoring has no well-defined "register a new address instead" escape
// hatch until the rotation index is known (§2).
expect(find.text("Register a new address instead"), findsNothing);
fields = find.byType(TextField); fields = find.byType(TextField);
expect(fields, findsNWidgets(2)); // address (carried over), server key expect(fields, findsNWidgets(2)); // address (carried over), server key
await tester.enterText(fields.at(1), b32encode(randomBytes(32))); await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
@ -87,84 +104,71 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget); expect(find.text("Inbox"), findsOneWidget);
expect(store.seed(), seed); expect(store.master(), master);
expect(store.account()!.user, "randogoth"); expect(store.account()!.user, "randogoth");
}); });
testWidgets("restore without an address recalls from the register step", testWidgets("restore requires an address before it will submit",
(tester) async { (tester) async {
final store = storeB; final store = storeB;
final seed = randomBytes(32);
await tester.pumpWidget(app(store)); await tester.pumpWidget(app(store));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text("Restore From Seed")); await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
var fields = find.byType(TextField); final fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(seed)); await tester.enterText(fields.at(0), hex(randomBytes(32)));
await tester.tap(find.text("Restore")); await tester.tap(find.text("Restore"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// The register step: address, server key, optional invite token. // Nothing was submitted, so nothing was persisted; still on this step.
fields = find.byType(TextField); expect(find.text("Restore"), findsOneWidget);
expect(fields, findsNWidgets(3)); expect(find.text("Inbox"), findsNothing);
await tester.enterText(fields.at(0), "randogoth@smol.place"); expect(store.master(), isNull);
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
await tester.tap(find.text("Already registered? Recall"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.account()!.user, "randogoth");
}); });
testWidgets( testWidgets(
"restoring again after an incomplete attempt replaces the identity " "restoring after an abandoned Create Identity attempt replaces it "
"instead of refusing it", (tester) async { "instead of refusing it", (tester) async {
final store = storeC; final store = storeC;
final abandonedSeed = randomBytes(32); final realMaster = randomBytes(32);
final realSeed = randomBytes(32);
await tester.pumpWidget(app(store)); await tester.pumpWidget(app(store));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// First attempt: restore a seed but never finish registering — lands on // First attempt: create a fresh identity but never finish registering —
// the register step, identity set, no account bound. // lands on the backup step, master set, no account bound.
await tester.tap(find.text("Restore From Seed")); await tester.tap(find.text("Create Identity"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
var fields = find.byType(TextField); expect(store.master(), isNotNull);
await tester.enterText(fields.at(0), hex(abandonedSeed));
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
expect(store.seed(), abandonedSeed);
expect(store.account(), isNull); expect(store.account(), isNull);
// Simulate returning to onboarding later (e.g. a cold restart). Pumping // Simulate returning to onboarding later (e.g. a cold restart). Pumping
// app(store) directly would just rebuild the existing OnboardingScreen // app(store) directly would just rebuild the existing OnboardingScreen
// state in place (still parked on the register step) rather than really // state in place rather than really restarting, so tear the tree down
// restarting, so tear the tree down first to force a fresh app state — // first to force a fresh app state — HomeGuard then sends an
// HomeGuard then sends an identity-without-account back to welcome. // identity-without-account back to welcome.
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(app(store)); await tester.pumpWidget(app(store));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text("Create Identity"), findsOneWidget); expect(find.text("Create Identity"), findsOneWidget);
// Restoring a different seed must not throw "identity already exists". // Restoring a different master must not throw "identity already exists".
await tester.tap(find.text("Restore From Seed")); await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
fields = find.byType(TextField); final fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(realSeed)); await tester.enterText(fields.at(0), hex(realMaster));
await tester.enterText(fields.at(1), "randogoth@smol.place"); await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore")); await tester.tap(find.text("Restore"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Lands on the recall-framed register step (no pin yet); pin it and finish. // Lands on the recall-framed register step (no pin yet); pin it and finish.
fields = find.byType(TextField); expect(find.byType(TextField), findsNWidgets(2));
expect(fields, findsNWidgets(2)); await tester.enterText(find.byType(TextField).at(1), b32encode(randomBytes(32)));
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
await tester.tap(find.text("Pin and Recall")); await tester.tap(find.text("Pin and Recall"));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget); expect(find.text("Inbox"), findsOneWidget);
expect(store.seed(), realSeed); expect(store.master(), realMaster);
}); });
} }

View file

@ -163,24 +163,28 @@ void main() {
}); });
test("frontmatter parses and fails closed (§5.5)", () { test("frontmatter parses and fails closed (§5.5)", () {
const spec = const inReplyTo =
"---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here."; "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5";
final spec =
"---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
final parsed = parseFrontmatter(spec); final parsed = parseFrontmatter(spec);
expect(parsed.fields["Subject"], "Re: the thing"); expect(parsed.fields["subject"], "Re: the thing");
expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d"); expect(parsed.fields["in-reply-to"], inReplyTo);
expect(parsed.body, "Body text starts here."); expect(parsed.body, "Body text starts here.");
// a malformed line invalidates the whole block, which fails closed toward display // a malformed line invalidates the whole block, which fails closed toward display
expect(parseFrontmatter("---\nno colon here\n---\nrest").body, expect(parseFrontmatter("---\nno colon here\n---\nrest").body,
"---\nno colon here\n---\nrest"); "---\nno colon here\n---\nrest");
expect(parseFrontmatter("---\nSubject: x\nno end").body, expect(parseFrontmatter("---\nSubject: x\nno end").body,
"---\nSubject: x\nno end"); "---\nSubject: x\nno end");
expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["A"], "1"); expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["a"], "1");
// keys compare case-insensitively; the first occurrence wins (§5.5)
expect(parseFrontmatter("---\nSubject: x\nsubject: y\n---\ntext").fields["subject"], "x");
expect(buildFrontmatter(const {}, "---\nactual body"), expect(buildFrontmatter(const {}, "---\nactual body"),
"---\n---\n---\nactual body"); "---\n---\n---\nactual body");
expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere"); expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere");
expect(buildFrontmatter(const {}, "plain"), "plain"); expect(buildFrontmatter(const {}, "plain"), "plain");
expect( expect(
parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"], parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["subject"],
isNull); isNull);
}); });
@ -195,6 +199,9 @@ void main() {
expect(parsed.identity, key); expect(parsed.identity, key);
expect(parsed.user, "bob"); expect(parsed.user, "bob");
expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>())); expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>()));
// §3: never two separators in a row
expect(() => parseAddress("a..b@h"), throwsA(isA<SmolError>()));
expect(parseAddress("a.b_c@h").user, "a.b_c");
// §3: fingerprints are the first 20 base32 characters in groups of four // §3: fingerprints are the first 20 base32 characters in groups of four
final b32 = b32encode(key); final b32 = b32encode(key);
expect( expect(
@ -210,27 +217,31 @@ void main() {
}); });
test("rotation chains validate, break and oversize per §7", () { test("rotation chains validate, break and oversize per §7", () {
const username = "alice";
final old = identityFromSeed(randomBytes(32)); final old = identityFromSeed(randomBytes(32));
final mid = randomBytes(32); final mid = randomBytes(32);
final fresh = randomBytes(32); final fresh = randomBytes(32);
final when = nowSeconds(); final when = nowSeconds();
final chain = [ final chain = [
makeCert(old, mid, when), makeCert(username, old, mid, when),
makeCert(identityFromSeed(mid), fresh, when), makeCert(username, identityFromSeed(mid), fresh, when),
]; ];
expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue); expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
expect(walkChain(old.publicKey, old.publicKey, []), isTrue); expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue);
expect( expect(
walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
isFalse); isFalse);
final forged = List<Uint8List>.from(chain); final forged = List<Uint8List>.from(chain);
forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when); forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when);
expect( expect(
walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse); walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
expect( expect(
walkChain(old.publicKey, ed25519PublicKey(fresh), walkChain(username, old.publicKey, ed25519PublicKey(fresh),
List.filled(17, chain[0])), List.filled(17, chain[0])),
isFalse); isFalse);
// a chain signed for a different username must not validate (§7)
expect(
walkChain("bob", old.publicKey, ed25519PublicKey(fresh), chain), isFalse);
expect(chain[0].length, certLen); expect(chain[0].length, certLen);
}); });

View file

@ -63,11 +63,11 @@ void main() {
expect(saved.key, identity.publicKey); expect(saved.key, identity.publicKey);
}); });
test("export never contains the seed and round-trips through import", test("export never contains the master secret and round-trips through import",
() async { () async {
final a = await freshStore("export"); final a = await freshStore("export");
final b = await freshStore("round-trip"); final b = await freshStore("round-trip");
a.setIdentity(randomBytes(32)); a.setMaster(randomBytes(32));
a.pinServer("example.org", randomBytes(32)); a.pinServer("example.org", randomBytes(32));
a.saveContact("alice@example.org", randomBytes(32), true); a.saveContact("alice@example.org", randomBytes(32), true);
a.saveContact("alice@example.org", randomBytes(32), false); // history grows a.saveContact("alice@example.org", randomBytes(32), false); // history grows
@ -77,12 +77,12 @@ void main() {
MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6));
final data = a.exportData(); final data = a.exportData();
expect(data["gsmolExport"], 2); // sealed to the identity's seed, like gsmol expect(data["gsmolExport"], 2); // sealed to the identity's master, like gsmol
expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse); expect(jsonEncode(data).contains(hex(a.master()!)), isFalse);
// v2 is sealed to the exporting identity's seed — a restore-on-new-device // v2 is sealed to the exporting identity's master — a restore-on-new-device
// scenario, not a transfer to someone else's identity (see the test below). // scenario, not a transfer to someone else's identity (see the test below).
b.setIdentity(a.seed()!); b.setMaster(a.master()!);
final summary = await b.importData(data); final summary = await b.importData(data);
expect(summary.mailAdded, 2); expect(summary.mailAdded, 2);
expect(summary.pinsAdded, 1); expect(summary.pinsAdded, 1);
@ -96,14 +96,58 @@ void main() {
test("v2 import refuses a different identity's export", () async { test("v2 import refuses a different identity's export", () async {
final a = await freshStore("export-wrong-identity"); final a = await freshStore("export-wrong-identity");
final c = await freshStore("round-trip-wrong-identity"); final c = await freshStore("round-trip-wrong-identity");
a.setIdentity(randomBytes(32)); a.setMaster(randomBytes(32));
a.pinServer("example.org", randomBytes(32)); a.pinServer("example.org", randomBytes(32));
final data = a.exportData(); final data = a.exportData();
c.setIdentity(randomBytes(32)); // a different seed than a's c.setMaster(randomBytes(32)); // a different master than a's
expect(() => c.importData(data), throwsA(isA<SmolError>())); expect(() => c.importData(data), throwsA(isA<SmolError>()));
}); });
test("accept tokens: accept/block gate the sync set, tiers split the inbox view",
() async {
final store = await freshStore("accept-tokens");
final master = randomBytes(32);
store.setMaster(master);
final alice = randomBytes(32);
store.saveContact("alice@example.org", alice, true);
// No one accepted yet: an empty set is already complete, so it may sync.
var (sync, tokens) = store.tokenSet(master);
expect(sync, 1);
expect(tokens, isEmpty);
store.accept("alice@example.org", alice);
(sync, tokens) = store.tokenSet(master);
expect(sync, 1);
expect(tokens, [tokenFor(master, alice)]);
expect(store.accepted("alice@example.org")!.active, isTrue);
store.block("alice@example.org");
expect(store.accepted("alice@example.org")!.active, isFalse);
expect(store.tokenSet(master).$2, isEmpty);
// Re-accepting keeps the identity frozen at the original acceptance,
// so the token a correspondent already holds keeps working.
store.accept("alice@example.org", randomBytes(32));
expect(store.accepted("alice@example.org")!.identity, alice);
expect(() => store.block("bob@example.org"), throwsA(isA<SmolError>()));
// A restored master must not silently replace the server's set.
store.setSyncOk(false);
(sync, tokens) = store.tokenSet(master);
expect(sync, 0);
expect(tokens, isEmpty);
await store.storeIfNew(
"inbox", MailRecord("aa", randomBytes(64), receivedAt: 1, tier: tierMain));
await store.storeIfNew("inbox",
MailRecord("bb", randomBytes(64), receivedAt: 2, tier: tierRequests));
expect(store.listMessages("inbox").map((r) => r.id), ["aa"]);
expect(store.listMessages("requests").map((r) => r.id), ["bb"]);
expect(store.getMessage("requests", "bb"), isNotNull);
});
test("v1 legacy export still imports without an identity", () async { test("v1 legacy export still imports without an identity", () async {
final store = await freshStore("import-legacy-v1"); final store = await freshStore("import-legacy-v1");
final summary = await store.importData({ final summary = await store.importData({

View file

@ -190,7 +190,7 @@
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
"time": 1730000000, "time": 1730000000,
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
"id": "b05d15a2ab5293164eda564de02a6901", "id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553",
"unpadded_plaintext_len": 143 "unpadded_plaintext_len": 143
}, },
"noise": { "noise": {