flutter_smartschool 0.2.6 copy "flutter_smartschool: ^0.2.6" to clipboard
flutter_smartschool: ^0.2.6 copied to clipboard

An unofficial Dart library for the Smartschool platform.

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.

Bugs Code Smells Coverage Security Rating Quality Gate Status

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.

See example/get_recipients_from_sent_messages.dart to resolve the original recipient IDs for sent messages via getSentMessageRecipients.

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.
getSentMessageRecipients(msgId) Future<(List<MessageSearchUser>, List<MessageSearchUser>)> Returns the original recipients of a sent message with their numeric user IDs. The outbox reply-all compose page includes the authenticated user (sender) alongside the recipients; this method strips the sender out automatically. Use this instead of getReplyAllRecipients for messages in BoxType.sent.
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).
parseSentMessageRecipients(htmlBody) Like parseReplyAllRecipients but for the sent-folder compose page: additionally extracts the authenticated user's ID and removes them from the result. 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 /recent endpoint 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/down
  • Enter: open folder or download selected file
  • B / Backspace: go to parent folder
  • Q: 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 #

  1. Call login once at the start of the session.
  2. Call get_page on the target module URL (e.g. /?module=Messages&file=composeMessage).
  3. Inspect the returned HTML/JSON to identify form field names, JS config blobs, and API endpoints.
  4. Use post_form or request to replicate browser actions.
  5. 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.yml local and private. It is listed in .gitignore and must never be committed.

1
likes
160
points
373
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

An unofficial Dart library for the Smartschool platform.

Repository (GitHub)
View/report issues

License

GPL-3.0 (license)

Dependencies

cookie_jar, dio, dio_cookie_manager, html, otp, path, xml, yaml

More

Packages that depend on flutter_smartschool