flutter_smartschool
An unofficial Dart client library for the Smartschool school platform. It handles authentication (including TOTP 2FA and birthday-based account verification), cookie persistence, and the mix of XML-protocol and JSON/REST endpoints that Smartschool uses internally.
Repository: yvanvds/dartschool
Unofficial. This library reverse-engineers the private Smartschool web API. It is not endorsed by or affiliated with Smartschool. Use responsibly.
Features
- Authenticated Smartschool client with cookie persistence and MFA/account-verification support.
- Full messaging workflow (
MessagesService): list, read, attachments, recipient search, send, archive, trash, labels, reply-all recipient resolution. - Event-driven message detection: notification counter stream with debounced incremental inbox refresh; wires into any notification source (polling bridge or WebSocket).
- Intradesk read support (
IntradeskService): root/folder listing and file download. - Interactive terminal browser for Intradesk: example/intradesk_browser.dart.
Installation
Add the package to pubspec.yaml:
dependencies:
flutter_smartschool: ^0.2.5
or directly from GitHub (dartschool) while iterating:
dependencies:
flutter_smartschool:
git:
url: https://github.com/yvanvds/dartschool.git
Quick start
import 'package:flutter_smartschool/flutter_smartschool.dart';
Future<void> main() async {
// 1. Provide credentials β pick one of the three credential classes.
final creds = PathCredentials(); // reads credentials.yml from disk
// 2. Create an authenticated client.
final client = await SmartschoolClient.create(creds);
await client.ensureAuthenticated();
// 3. Use a service.
final messages = MessagesService(client);
// List the 20 most-recent inbox headers.
final headers = await messages.getHeaders();
for (final msg in headers) {
print('${msg.date} ${msg.sender}: ${msg.subject}');
}
// Fetch the full body and attachment list of the first message.
final full = await messages.getMessage(headers.first.id);
print(full?.body);
final attachments = await messages.getAttachments(headers.first.id);
for (final a in attachments) {
print(' π ${a.name} (${a.size})');
final bytes = await a.download(client);
print(' downloaded ${bytes.length} bytes');
}
// Send a message to yourself.
final myself = await messages.getCurrentUserAsRecipient();
await messages.sendMessage(
to: [myself],
subject: 'Hello from flutter_smartschool',
bodyHtml: '<p>It works!</p>',
);
}
See example/send_message_lifecycle_example.dart for a complete send β inbox poll β archive β trash flow.
See example/mark_read_toggle_example.dart for toggling the read/unread status of a message.
See example/reply_all_recipients_example.dart to scan the inbox for messages with multiple recipients and resolve their user IDs via getReplyAllRecipients.
For thread grouping on real inbox headers, see example/message_threading_headers_example.dart.
For Intradesk navigation and file downloads, see example/intradesk_browser.dart (interactive text UI).
Credentials
Three credential classes are provided, all extending the abstract Credentials base.
| Class | Source |
|---|---|
AppCredentials |
Inline constructor arguments |
EnvCredentials |
Environment variables (SMARTSCHOOL_USERNAME, SMARTSCHOOL_PASSWORD, SMARTSCHOOL_MAIN_URL, SMARTSCHOOL_MFA) |
PathCredentials |
credentials.yml file β searched from cwd upwards, then ~/.cache/smartschool/ |
credentials.yml format:
username: john.doe
password: s3cr3t
main_url: school.smartschool.be
mfa: 2010-05-15 # date for account-verification, or Base32 secret for TOTP
If you need mfa, open your smartschool profile, two-factor authentication, add authenticator app. When a QR code is displayed, choose 'I do not have a camera'. A code is shown and that's the one you need.
SmartschoolClient
The authenticated HTTP client. Create one instance per session and share it across services.
final client = await SmartschoolClient.create(credentials);
await client.ensureAuthenticated();
| Method / getter | Description |
|---|---|
SmartschoolClient.create(credentials) |
Factory β creates the Dio client, configures cookie jar, returns ready instance |
ensureAuthenticated() |
Triggers login if not already done; safe to call repeatedly |
clearCookies() |
Deletes persisted cookies (use this for explicit logout/session reset). |
getRaw(path) |
Authenticated GET β response body as String |
getJson(path, {query}) |
Authenticated GET with JSON Accept header β decoded dynamic |
postFormRaw(path, fields) |
application/x-www-form-urlencoded POST β String |
postFormEncodedRaw(path, body) |
Same but accepts a pre-encoded body string |
postMultipartRaw(path, formData) |
multipart/form-data POST β String |
postXml(...) |
Posts to the legacy XML dispatcher and returns parsed element maps |
notificationCounterUpdates |
Stream<NotificationCounterUpdate> β broadcast stream of counter events emitted by any notification source |
emitNotificationCounterUpdate({moduleName, counter, isNew, source, timestamp}) |
Push a NotificationCounterUpdate into the stream; returns false if the stream is already closed |
dispose({force}) |
Closes the notification stream and the underlying Dio client |
dio |
Exposes the underlying Dio instance for advanced / dev use |
MessagesService
All message operations. Construct with a SmartschoolClient.
final messages = MessagesService(client);
Reading
| Method | Returns | Description |
|---|---|---|
getHeaders({boxType, boxId, sortBy, sortOrder, alreadySeenIds}) |
List<ShortMessage> |
List message headers for any box. Pass alreadySeenIds for lightweight polling. |
getArchiveHeaders({boxId, sortBy, sortOrder, alreadySeenIds}) |
List<ShortMessage> |
Convenience wrapper for the archive folder β resolves the box ID automatically. |
getArchiveBoxId() |
Future<int> |
Returns the archive folder's numeric box ID (cached; falls back to 208). |
getMessage(msgId, {boxType, includeAllRecipients}) |
Future<FullMessage?> |
Fetches the full HTML body, receiver lists, and metadata for a message. Pass includeAllRecipients: true to receive every recipient name in receivers/ccReceivers/bccReceivers; the default truncates the list and exposes the hidden count via totalNrOther* fields instead. |
getReplyAllRecipients(msgId, {boxType}) |
Future<(List<MessageSearchUser>, List<MessageSearchUser>)> |
Returns all To and CC recipients with their numeric user IDs by parsing the reply-all compose page. Use this when you need IDs for a subsequent sendMessage reply-all. |
getAttachments(msgId, {boxType}) |
Future<List<MessageAttachment>> |
Returns the attachment list for a message. |
Attachment bytes can be downloaded from each MessageAttachment:
final attachments = await messages.getAttachments(messageId);
for (final attachment in attachments) {
final bytes = await attachment.download(client);
print('${attachment.name}: ${bytes.length} bytes');
}
Mutating
| Method | Returns | Description |
|---|---|---|
markRead(msgId, {boxType}) |
Future<MessageChanged?> |
Marks a message as read. getMessage does not flip the read state; call this after (or alongside) getMessage when you want the server to record the message as opened. Idempotent β safe to call on an already-read message. |
markUnread(msgId, {boxType, boxId}) |
Future<MessageChanged?> |
Marks a message as unread. |
setLabel(msgId, label, {boxType}) |
Future<MessageChanged?> |
Applies a colour flag (MessageLabel). Use noFlag to clear. |
moveToTrash(msgId) |
Future<MessageDeletionStatus?> |
Moves a message to the trash. |
moveToArchive(msgIds) |
Future<List<MessageChanged>> |
Archives one or more messages (REST endpoint). |
Composing & searching
| Method | Returns | Description |
|---|---|---|
getCurrentUserAsRecipient() |
Future<MessageSearchUser> |
Returns the currently-logged-in user as a compose recipient (reads IDs from compose page JS β safe and reliable). |
searchRecipients(query) |
Future<List<MessageSearchResult>> |
JSON-based recipient search; results lack ssId β use searchRecipientsForCompose when sending. |
searchRecipientsForCompose(query) |
Future<(List<MessageSearchUser>, List<MessageSearchGroup>)> |
Compose-form XML search; results carry ssId/userLt required by sendMessage. |
sendMessage({to, cc, bcc, toGroups, ..., subject, bodyHtml, attachmentPaths}) |
Future<void> |
Full multi-step send: loads compose form, registers recipients, uploads attachments, submits. |
Thread subject helpers
| Method | Returns | Description |
|---|---|---|
threadSubjectKey(subject) |
String |
Normalises a subject for thread grouping by removing leading reply/forward prefixes (Re:, Fwd:, FW:, AW:, WG:). |
ensureReplySubject(subject, {replyPrefix}) |
String |
Produces a reply subject with exactly one prefix (default Re:), avoiding Re: Re: .... |
Event-driven message detection
MessagesService can react to external notification signals (e.g. a WebSocket push or a polling bridge) and trigger a debounced incremental inbox refresh automatically.
final messages = MessagesService(client);
// 1. Seed the seen-ID baseline so only genuinely new messages trigger events.
final initial = await messages.getHeaders();
messages.seedIncrementalSeenIds(initial.map((m) => m.id));
// 2. Bind MessagesService to the client's notification stream.
// The subscription is cancelled automatically by dispose().
messages.bindNotificationCounterStream(client.notificationCounterUpdates);
// 3. React to new messages detected by the debounced refresh.
messages.messageCounterUpdates.listen((update) async {
final newHeaders = await messages.refreshHeadersOnMessageCounter(update);
for (final msg in newHeaders) {
final full = await messages.getMessage(msg.id);
print('[${msg.date}] ${msg.sender}: ${msg.subject}');
print(full?.body);
}
});
// 4. Fire a notification β normally this comes from a WebSocket, but you can
// emit one manually or from a polling bridge.
client.emitNotificationCounterUpdate(
moduleName: 'Messages',
counter: 3,
isNew: true,
source: 'websocket',
);
// 5. Clean up when done.
await messages.dispose();
await client.dispose();
Event-driven API
| Method / getter | Returns | Description |
|---|---|---|
messageCounterUpdates |
Stream<MessageCounterUpdate> |
Broadcast stream emitting one event per debounce window when the counter rises. |
handleNotificationCounterUpdate(update) |
bool |
Processes a NotificationCounterUpdate for the Messages module; deduplicates identical consecutive counter values; returns true if a new MessageCounterUpdate was emitted. |
bindNotificationCounterStream(stream) |
StreamSubscription |
Subscribes to any Stream<NotificationCounterUpdate> and pipes Messages events through handleNotificationCounterUpdate. |
seedIncrementalSeenIds(ids, {boxType, boxId, sortBy, sortOrder}) |
void |
Populates the per-mailbox seen-ID baseline so the first real refresh only surfaces messages newer than the seed. |
refreshHeadersIncremental({boxType, boxId, sortBy, sortOrder, debounceWindow}) |
Future<List<ShortMessage>> |
Debounced incremental fetch β concurrent calls within the window share the same in-flight request. |
refreshHeadersOnMessageCounter(update, {boxType, boxId, sortBy, sortOrder, debounceWindow}) |
Future<List<ShortMessage>> |
Convenience wrapper: calls refreshHeadersIncremental using context from a MessageCounterUpdate. |
dispose() |
Future<void> |
Cancels debounce timers, closes the message counter stream, and cancels any bound notification subscription. |
Polling bridge pattern
If no WebSocket is available, use the existing alreadySeenIds polling parameter as a bridge:
final seen = <int>{};
Timer.periodic(Duration(seconds: 30), (_) async {
final newHeaders = await messages.getHeaders(alreadySeenIds: seen.toList());
if (newHeaders.isNotEmpty) {
seen.addAll(newHeaders.map((m) => m.id));
client.emitNotificationCounterUpdate(
moduleName: 'Messages',
counter: seen.length,
isNew: true,
source: 'poll',
);
}
});
See example/notification_listener_full_message_example.dart for a complete runnable demo. See example/message_change_stream_example.dart for a stream-binding walkthrough with synthetic events.
Static parsers (exposed for testing)
| Method | Description |
|---|---|
parseHiddenFields(htmlBody) |
Extracts all <input type="hidden"> nameβvalue pairs from an HTML page. |
parseComposeCurrentUserIds(htmlBody) |
Extracts (userId, ssId, userLt) from the window.tinymceInitConfig block. |
parseArchiveBoxIdFromMessagesHtml(htmlBody) |
Extracts the archive folder box ID from the Messages module HTML. |
parseReplyAllRecipients(htmlBody) |
Extracts To and CC recipients with numeric IDs from a reply-all compose page (parses div.receiverSpan elements). Returns (toList, ccList). |
IntradeskService
Access to the Smartschool Intradesk document repository. Construct with a SmartschoolClient.
final intradesk = IntradeskService(client);
// Root listing
final root = await intradesk.getRootListing();
for (final folder in root.folders) {
print('${folder.name} hasChildren: ${folder.hasChildren}');
}
// Drill into a sub-folder
final sub = await intradesk.getFolderListing(root.folders.first.id);
// Download a file
final bytes = await intradesk.downloadFile(sub.files.first.id);
await File('output.docx').writeAsBytes(bytes);
Methods
| Method | Returns | Description |
|---|---|---|
getRootListing() |
Future<IntradeskListing> |
Root-level folders, files, and weblinks. |
getFolderListing(folderId) |
Future<IntradeskListing> |
Folders, files, and weblinks inside the identified folder. |
downloadFile(fileId) |
Future<Uint8List> |
Raw bytes of the identified file. |
Not yet implemented: file upload β the server-side endpoint and required form fields have not been captured safely.
Not scoped: the/recentendpoint returns an SPA HTML shell, not a JSON listing.
Example
Run the interactive browser:
dart run example/intradesk_browser.dart
Controls:
U/D: move selection up/downEnter: open folder or download selected fileB/Backspace: go to parent folderQ: quit
Models
ShortMessage
Returned by getHeaders / getArchiveHeaders. Fields: id, sender, subject, date, unread, deleted, attachment, coloredFlag, allowReply, realBox, β¦
FullMessage
Returned by getMessage. Adds: body (HTML), receivers, ccReceivers, bccReceivers, canReply, senderPicture, totalNrOtherToReceivers, totalNrOtherCcReceivers, totalNrOtherBccReceivers (count of recipients hidden behind a "show more" link when includeAllRecipients is false), β¦
MessageAttachment
Returned by getAttachments. Fields: fileId, name, mime, size, icon, wopiAllowed, order.
Use attachment.download(client) to fetch raw bytes for a specific attachment.
MessageSearchUser / MessageSearchGroup
Used as recipients in sendMessage. Key fields: userId/groupId, ssId, userLt, displayName.
MessageChanged / MessageDeletionStatus
Returned by mutation operations. Carry the id of the affected message and a newValue / status field.
NotificationCounterUpdate
Transport-agnostic event produced by any notification source (WebSocket, polling bridge, or manual emit).
| Field | Type | Description |
|---|---|---|
moduleName |
String |
Smartschool module name (e.g. 'Messages', 'Ticket'). |
counter |
int |
Current badge count reported by the source. |
isNew |
bool |
Whether the source flagged this as a new-item signal. |
source |
String |
Opaque tag identifying the origin ('websocket', 'poll', β¦). |
timestamp |
DateTime |
When the event was created. |
MessageCounterUpdate
Produced by MessagesService after deduplication and emitted on messageCounterUpdates.
| Field | Type | Description |
|---|---|---|
counter |
int |
New message counter value. |
previousCounter |
int? |
Previous value (null on first event). |
isNew |
bool |
Forwarded from the source NotificationCounterUpdate. |
source |
String |
Forwarded source tag. |
timestamp |
DateTime |
When the event was created. |
IntradeskListing
Returned by getRootListing / getFolderListing. Fields: folders (List<IntradeskFolder>), files (List<IntradeskFile>), weblinks (raw maps).
IntradeskFolder
Fields: id, name, color, state, visible, confidential, parentFolderId (empty at root), hasChildren, isFavourite, capabilities (IntradeskFolderCapabilities), platform, dateCreated, dateChanged, dateStateChanged.
IntradeskFile
Fields: id, name, state, parentFolderId, ownerId, confidential, isFavourite, currentRevision (IntradeskFileRevision?), capabilities (IntradeskFileCapabilities), platform, dateCreated, dateChanged, dateStateChanged.
IntradeskFileRevision
Current revision metadata. Fields: id, fileId, fileSize, label, dateCreated, owner (IntradeskFileOwner).
Enums
| Enum | Values |
|---|---|
BoxType |
inbox, draft, scheduled, sent, trash |
SortField |
date, from, readUnread, attachment, flag |
SortOrder |
asc, desc |
RecipientType |
to, cc, bcc |
MessageLabel |
noFlag, greenFlag, yellowFlag, redFlag, blueFlag |
Exceptions
| Exception | Thrown when |
|---|---|
SmartschoolAuthenticationError |
Login fails or session has expired |
SmartschoolComposeError |
The compose form cannot be parsed, or the server rejects the message |
SmartschoolAttachmentUploadError |
An attachment upload step fails |
Smartschool Researcher MCP Server
This repository includes a local MCP server that wraps the DevInspector HTTP client so Copilot Agent mode can explore live Smartschool endpoints directly. It is intended for development and reverse-engineering only.
- Entrypoint:
bin/smartschool_researcher_mcp.dart - VS Code config:
.vscode/mcp.json(pre-configured) - Credentials:
credentials.yml(auto-discovered; never commit this file)
Available tools
| Tool | Description |
|---|---|
login |
Authenticates with credentials.yml, or with inline username/password/mainUrl. |
login_status |
Checks whether the current MCP session has an active Smartschool session. |
get_page |
Authenticated GET β statusCode, headers, body. |
get_json |
GET with JSON Accept header β parsed json field in addition to raw body. |
post_form |
Authenticated application/x-www-form-urlencoded POST. |
request |
Generic tool: arbitrary method, headers, query params, body, content type. |
Typical agent workflow
- Call
loginonce at the start of the session. - Call
get_pageon the target module URL (e.g./?module=Messages&file=composeMessage). - Inspect the returned HTML/JSON to identify form field names, JS config blobs, and API endpoints.
- Use
post_formorrequestto replicate browser actions. - Design the Dart service method and models from the confirmed response shape.
Pass maxBodyChars to any tool to truncate large responses before they fill the context window.
Keep
credentials.ymllocal and private. It is listed in.gitignoreand must never be committed.
Libraries
- flutter_smartschool
- Unofficial Dart library for the Smartschool platform.
- flutter_smartschool_dev
- Development tools β do NOT import in production code.