Skip to content

The full client: mail, contacts, calendars

Sioul becomes a complete client while keeping its rule: only what is used most is in view; the rest is one right click, or one fold, away. This page reviews what mail, address-book and calendar clients usually offer (Thunderbird, Apple Mail, Outlook, Gmail, Fastmail, K-9/Thunderbird for Android, Evolution, KOrganizer), sorts each feature into three tiers, and says how Sioul shows it.

  • In view: on screen without asking, at most six actions at a time, in two groups (answering; filing away).
  • One step away: the right-click menu (also the "…" button and the Menu key, for the keyboard), or a folded section that opens in place.
  • Settings: beside what they change: a ⚙ at the end of each page's first row, each setting with a sentence on what it does, saved at once into the configuration file, its comments kept (settings.rs, config::set_value); how long text reads (font, size, line spacing) in the ⚙ of the pages where messages are read, Mail and the Porch, and behind "Aa" in Notes (one setting, shown where it is used).
  • Accounts, in three tabs: your accounts, one card per address with each of its services (mail; calendars, tasks and contacts; Google), each with a switch (off, it keeps its settings and is neither synced nor shown; its calendars wait in their folder, vdir::set_account_off), and for mail its rank and your name and signature in view, while what it is for, whether it is protected against harassment, how far back and how often are folded under "Settings for this address" (made when unfolded: a card holding them all took half a second on a phone), its server and folders folded too; "What this server offers" asks the server (sioul_sync::scout: the provider's mail settings, calendars and contacts at the well-known addresses, a Nextcloud's version and its apps, Files, Notes, Talk, Deck, said as in Sioul or not yet), and adds the mail of an address whose calendars are here with their password; add an account; encryption. Who may reach you, and when, is not an Accounts tab any more: Settings ▸ What reaches you holds it, the four lists with it (attention.md, "The interface"). Sites are not accounts: they are [[site]], made and changed on the Sites page.
  • Settings, in tabs: display, hours, reminders and notifications, your folder and sharing, invoices.
  • One setting, one place, by what owns or uses it: an object's own settings where the object is made (an address's area, history, pace and shield on its card in Accounts; a project's routes on its page; a bank account's budgets and rules on its card); a page's display and behaviour behind its ⚙ (Mail: conversations, pace, how messages read, the lists you left; Tasks: their hours, types, lists, the categories of work and of your own, GitHub; the Porch: the projects it shows, letters, its sorting); what belongs to no single page in Settings (language, colours, your folder, the hours, reminders, invoices, sharing); what reaches you, and when, is Settings ▸ What reaches you (attention.md); what sets a phone up, Settings ▸ This phone. A test fails when a setting shows in two places (settings::tests::each_page_has_its_own).

Calm first

A full client must not bring back the inbox that screams. These rules come before any feature; when a feature conflicts with one, the rule wins (design.md, research.md).

  1. Nothing counts at you. No unread numbers, no badges, no red. A folder with something new shows a small dot; how many is said in words when you open it.
  2. New mail does not interrupt outside its times. It waits in the Porch; at the times your lists let it through, one quiet notification per batch says how many, in words, and who (porch.md, "Notifications"), and mail that waited is told once when its time begins; the codes and links you ask sites for notify at once, and what you send yourself comes in any hour. The client's folders are for when you choose to look.
  3. Undo, never "are you sure?". Archiving, deleting, junking and moving happen at once, with a quiet "Undo" for ten seconds; the server is told only after that. No confirmation dialogs, nothing lost to a slip.
  4. Sending has a breath. "Send" waits ten seconds with an "Undo" before the message leaves; drafts save as you type, so closing the window never loses anything.
  5. One thing at a time. The list shows who, what and when, without previews; the reader shows one message; quotes, attachments and details stay folded.
  6. Plain words, no alarm. "Move to the trash", "Junk", "Not now"; destructive actions are labelled plainly, never red; errors say what happened and what to do, in the status line, never in a pop-up.
  7. A short past by default. A folder shows its last two weeks; "Earlier" opens the rest on request, so no list is endless. All the mail is kept all the same: after a first sync of two weeks, older mail comes a round at a time (300 messages, the newest first), each round's size asked first (RFC822.SIZE) so the disk keeps a reserve of 5 GB or a twentieth of it, whichever is more; when older mail waits for room, the status line says so once. Kept for searching, and for what will learn on this device what is spam and what matters.
  8. The same place for the same thing. Writing, the message's actions, the folders: always in the same spot, all reachable from the keyboard.
  9. Calendars without pressure. What comes, from today; nothing "overdue" in red; one silent reminder a quarter of an hour before (before its time to get there; yours to set, each event its own), one the working day before, and the event's own alarms (reminders.md).
  10. Contacts are names first. Details fold until opened.

Mail

Feature Tier How
Folders: inbox, sent, drafts, archive, junk, trash in view the left column, by account; other folders folded under "More folders"
The Porch (new mail, sorted and summarised) in view its own place, as now
Read a message in view headers apart, attachments folded, quotes folded (porch.md)
Write in view one button, always in the same place
Reply; reply to all (shown only when there are other recipients); forward in view above the message
Archive, move to the trash, junk in view above the message, done at once with "Undo" for ten seconds (rule 3)
Unsubscribe from a list in view above a message that names a way out (List-Unsubscribe), in one click, with ten seconds to undo ("Unsubscribing", below)
Mark unread or read, flag one step right click; opening a message marks it read; a small dot, not bold, marks the unread
Move to a folder in view dragged onto a folder of the left column, alone or with the selection it is in; or right click (the Menu key), Move to…
Copy to a folder later
Block the sender, let the sender in one step right click, and in the reader
Show the source one step right click
Save as .eml later
Empty the trash, the junk later
Search in view one field at the top of a folder: sender, recipient, subject
Search by conditions: sender, recipients, subject, text, attachments, the day it arrived, size, address, folder, read or flagged one step More… beside the search: a column in the folders' place, the results in the usual list ("Searching", below)
Threads one step a message's earlier messages are folded in it; a thread view comes later
Cc, Bcc, sender account one step folded in the writing window ("Cc, Bcc"); the account follows the message answered
Attachments when writing in view "Attach", and dropping files on the window
Drafts in view saved as you type; listed in Drafts
Signature settings per account, in Markdown
Filters (rules): conditions on new mail, then what is done on its server settings Mail ▸ ⚙, Filters: one list for every address, each filter in a sentence ("Filters", below)
Send later, read receipts, templates later

Writing in Markdown

  • You write Markdown; the message goes out as HTML (CommonMark with tables and strikethrough, pulldown-cmark) and as plain text (the Markdown itself, readable as is), in multipart/alternative.
  • Replies and forwards: your text is converted first, then the quoted message is appended below it, already HTML (made safe, porch.md) or text converted, in a blockquote, under "On …, X wrote:"; a forward carries the original headers and its attachments.
  • Threading: In-Reply-To and References, so every client threads the answer.
  • Sending is deliberate: a button or Ctrl+Enter, never automatic; then a copy goes to Sent (Gmail keeps its own).

Reading what comes from outside

Whoever writes to you, and any server you use, chooses words Sioul shows. None of them loads anything, runs anything, or holds the window.

  • A message's text: HTML made safe in Rust (reading::safe_html, ammonia): only text, structure and web or mail links; no picture, style, script, form or frame, so no tracking pixel fetches (porch.md). Plain text is escaped, its links made (reading::linkify).
  • Short words from outside: a sender's name, a subject, a contact's or a list's name, an event's title from a shared calendar or an invitation, a folder's or a calendar's name from a server, a site's or a caller's name. Qt would draw them as HTML when they look like it, a picture in them fetched, and a button holding one crashed the window; every page draws them as plain text (qt-quick.md). They are kept as written: a sender's name becomes a contact's as it is (pim::add_sender), and only the drawing changes. tools/check-plain-text.py holds every page to it, and tools/qml-test.sh tst_plaintext.qml shows such words in Sioul's buttons, menus, tooltips, lists, dialog titles and a person's sheet while a stand-in web server notes what is asked of it: nothing is fetched, nothing crashes (the security review of 10 October 2026).
  • Never held: a message is read on a thread of its own, the invitation it carries with it (read_message), and its page says "Opening the message…" meanwhile, so that a large or crafted one never holds the window, nor the reminders of doses with it. The work grows with the message, never with its square: the HTML's spacer blocks go in one pass (reading::without_spacers); the blocks of repeated headers, the attributions and the next line that is not blank are found by reading the lines once, from the end (reading::parts_with); and before ammonia's parser, which looks through every element still open at most tags, elements nested deeper than 512 levels are left out, their words kept, where web browsers stop too (reading::shallow). Before, 8,000 lines of "From: x" (64 KB) took 36 seconds, and 8,000 empty blocks nested in one another nearly 3; each now takes milliseconds (reading::tests::hostile_html_reads_in_linear_time, hostile_plain_text_reads_in_linear_time, each bounded at 2 seconds in release, 20 in debug).

Writing to the server

Sioul was read-only. The client writes, and only when you act: flags with UID STORE, moves with UID MOVE (else copy, flag and UID EXPUNGE, RFC 4315), sending through SMTP submission (RFC 8314, TLS from the first byte or STARTTLS), and the sent copy with APPEND. Folders are found by their special use (RFC 6154: \\Sent, \\Drafts, \\Junk, \\Trash, \\Archive), else by name. Each folder is a Maildir++ subfolder; flags and deletions made elsewhere are brought back at each sync.

Signing in to the servers

  • A password, from the system keyring: IMAP LOGIN; SMTP AUTH PLAIN or LOGIN (mail-send); always over an encrypted connection. A password refused by Google's IMAP server is said as wanting an app password: Google takes no account password there.
  • An access token, for an account signed in with Google (auth = "google": Gmail and Google Workspace, google.md): SASL XOAUTH2, base64("user=" login "\x01auth=Bearer " token "\x01\x01") (crates/sioul-sync/src/sasl.rs). IMAP: AUTHENTICATE XOAUTH2 through async-imap's Authenticator, the token at the first continuation, an empty line at the next (Google's refusal, its reason as JSON). SMTP: AUTH XOAUTH2 sent by Sioul itself with mail-send's cmd, since mail-send's own XOAUTH2 answers the refusal's challenge with the token again instead of the empty line Google's page asks for. A refused token is asked again fresh, once (imap::sign_in_with_tokens, send::send); refused again, the account asks to be signed in again.
  • Generic: who gives the tokens is sasl::OAuth, Google only. Microsoft's accounts would be one more arm of it, with their own sign-in page and token endpoint; the IMAP and SMTP exchange stays the same. Not built.

Writing, as built

  • Line breaks are kept: a new line in the text is a new line in the mail, as in a chat; a blank line starts a paragraph.
  • The signature (Accounts, "Name and signature…") is put below the text when the draft starts, after the usual "-- " line, so you see and can change what goes out. Your name there is the one recipients see.
  • What you answer stays out of the way: the window says "Below your text: Jane's message of …, quoted"; the quote is added when the message leaves. A draft keeps its own copy of the message it answers, so archiving or deleting that message meanwhile changes nothing.
  • Drafts live on this device, saved as you type, listed under "Drafts" while there are some. They are not copied to the server's Drafts folder.
  • Answering your own message (from Sent) goes to whom it went to, as other clients do.

Writing from other applications

A mail link (mailto:) and what another application shares become a draft whose writing window opens; nothing leaves before Send (sioul_core::handed, crates/sioul-app/src/outside.rs).

  • mailto: (RFC 6068, sioul_core::mailto): the addresses, before "?" and in to, then cc, bcc, subject (MIME encoded words decoded), body, and In-Reply-To, which threads the answer with its message; each address once, in any case. Percent-decoding as UTF-8, a wrong byte replaced; "+" stays "+". Nothing else: no attach or attachment, which some programs take and which would let a web page name one of your files, no from.
  • Which address writes: the one asked (an address of yours chosen in Android's share sheet), else the first by priority among those that send, as for Write; From changes it.
  • Android: Sioul is in the share sheet, each address of yours too (Android 10 and later), and opens mail links; shared files are copied into Sioul as they come (android.md).
  • Linux: the desktop file makes Sioul a handler of x-scheme-handler/mailto (Exec=sioul-app %u). Chosen as the mail program (KDE: System Settings ▸ Default Applications; GNOME: Settings ▸ Apps ▸ Default Apps; anywhere: xdg-mime default com.aurelienpierre.Sioul.desktop x-scheme-handler/mailto), a link clicked runs sioul-app mailto:….
  • One Sioul at a time: a Sioul started while one of the same profile is open hands it its links and ends; the draft opens in the first, and a plain start brings its window forward. Before, a second start opened a second Sioul on the same files. They speak over a socket only you reach ($XDG_RUNTIME_DIR/sioul-<hash of the state folder>.sock; in a Flatpak, its own runtime folder), named after the profile, so that a demo or a test profile never reaches yours. A socket left by a Sioul that ended is replaced; a Sioul that does not answer within five seconds is passed over, and the new one opens. On Wayland, the second hands on the token its launcher gave it (XDG_ACTIVATION_TOKEN, xdg-activation-v1) with its links; the first gives it to Qt (qputenv, cpp/application.cpp) before the draft's window opens and asks to come forward, so that the compositor brings it to the front rather than leaving it behind the window you clicked in (KWin's focus stealing prevention). Not tried on KWin yet; on X11, nothing is handed on (DESKTOP_STARTUP_ID). The window's pictures and the demo (SIOUL_GRAB, SIOUL_DEMO), which run side by side on one made-up profile, neither hand over nor listen.
  • "Send by mail" in a file manager (xdg-email) names the files in attach=: not taken, as above. Dropping them on the writing window attaches them.
  • macOS would need the bundle to declare mailto (CFBundleURLTypes in packaging/macos/Info.plist) and an event filter, since macOS hands the address to the open program as an event (Qt's QFileOpenEvent), not as an argument; macOS keeps one program open by itself. Not done.
  • Windows would need the installer to register Sioul for mailto (HKCU\Software\Clients\Mail\Sioul with its Capabilities\URLAssociations, and RegisteredApplications), chosen in Settings ▸ Apps ▸ Default apps, and a way for a second program to reach the first (a named pipe: the standard library has no local socket there). Not done.

Selection, moves and conversations

  • Several messages: Ctrl+click adds or takes out, Shift+click takes everything from the last one clicked, Ctrl+A all, Escape none. On a touch screen, a long press opens the row's menu and Select begins a selection; a tap then chooses or leaves a message (as Ctrl+click), until none is chosen. A bar then marks them read, archives, deletes or moves them, under one "Undo"; on a phone it wraps over two lines.
  • Dragging a message (or the selection it is in) onto any folder of any account, from a folder or from a search's results (below): the folder under the pointer is outlined, a folded address unfolds, and while a search is open the folders come back in its place for as long as the drag lasts, at the same width, so the list does not move. With a mouse or a touchpad; on a touch screen a drag scrolls the list, and Move to… is the way (a phone shows the folders and the list one at a time anyway).
  • With the keyboard: the Menu key or Shift+F10 opens a row's menu at the row; Move to… there, or in the selection's bar, lists every address's folders, the one the messages are in left out when they share one.
  • In one connection: the messages of one account move as one set per folder (UID MOVE with a UID set, else UID COPY, \Deleted and UID EXPUNGE of the same set, RFC 4315), at most 200 UIDs a command, whatever their number (mailbox::act_many); reading, flags, archiving and the trash on a selection likewise, each account's server told once. Before, each message opened its own connection: sorting two hundred messages was two hundred logins, and marking them read two hundred at once. Junk, Not junk and the review's Spam and Not spam still go one at a time, for the spam filter's label log.
  • Into another account, the message is appended there with its flags (mailbox::move_across), and taken off the first only once the second server has it: if the copy fails, the original stays; if removing it fails, both remain. A message a search found on its server only is read whole from there first (move_across_from_server).
  • The Porch's rows are not dragged: the Porch has no folders beside it and is about deciding one message at a time; an open message's ⋮ has Move to….
  • Folders kept here or on the server only: right click on a folder, "Keep a copy here". A folder left on the server only shows muted, its copy here removed and where its sync stopped forgotten, so keeping it again brings it back whole (skip_folders in the account). The inbox is always kept. "New folder…" (under "More folders") makes one on the server (CREATE); "Delete this empty folder" takes one of yours off the server (DELETE), only when the server says it holds no message (STATUS MESSAGES), never a folder with a purpose (inbox, Sent, Drafts, trash, junk, archive).
  • Conversations (Mail ⚙, "By conversation"): messages tied by Message-ID, In-Reply-To and References (never by subject alone), the newest of the folder first with the size of the conversation; unfolded, the others, oldest first, your answers from Sent among them (threads.rs).
  • Real time: the checkbox in Mail, and Mail every minute in the card What reaches you now behind the status line's button (the Porch's box until part 5 of "What reaches you"), fetches every folder of every address each minute, and opens the Porch, until unticked.

Searching

Why, from the author's request of 7 October 2026: a feature for when it is needed, which not everybody needs nor needs often, so nothing of it shows until asked; and its main use is sorting an inbox into folders by hand, hence dragging messages onto folders.

  • The quick search stays as it was: one field at the top of a folder, by sender, recipient and subject, in that folder, every message kept.
  • More…, beside it, opens the search by conditions in the folders' place; on a phone, a page of its own, then the results as a page, Back going from the results to the fields, then to the folder. Nothing of it shows until asked. One condition at first, "Anywhere", holding what the quick search held; Add a condition for another; with two or more, Mail that meets all of them or any of them. Each condition is a field, a test and a value, on two lines, in plain words (MailSearch.qml).
  • The conditions are the mail filters' own (rules::Condition, rules::Join; "Filters", below): sender, recipients (To, Cc), subject, text, attachment (there is one, none, one named …), kind of attachment, the day it arrived (before, after, between), size, who the sender is to you, newsletter or list; Anywhere (sender, recipients, subject or text), which a filter takes too; and three of the search's own, added there for it (rules::Field::SEARCH), which no filter reads: Address, Folder (by purpose, every address's inbox or archive, or by name), Message (read, flagged, answered; "is not read" is unread). Text is compared as the filters compare it: case and accents aside. The same words describe a filter and a search, so that a search can become a filter: Make it a filter… opens the filters' editor with them (newFilterFrom in main.qml; "Filters", below).
  • The results fill the usual list, the newest first, each row saying where it is ("Inbox · noa@example.com"); a message opens beside the list as in a folder, the column folding away as the folders do, so one goes back and forth between the results and a message. Above them a sentence says what is searched ("Mail from …@bank.example…, arrived after 3 June, with an attachment."), and a line what was found ("12 messages here. Looking on the servers for the mail kept only there…"). Clear returns to the folder. At most 500 rows, the newest; past them, the line says how many there are.
  • Where: every folder of every address but the junk and the trash, unless a folder condition names them. The mail kept here first, at once, on every core, each message read as little as its conditions need: its headers for most; the whole of it only for its text, its attachments, or "Anywhere" when the headers do not hold the words (mailsearch::Stored).
  • Then the servers, after a pause in typing (at once with Enter), for what this device does not hold: the folders left on the server, and those whose older mail still waits for room on the disk (fetch::kept: a folder fetched whole needs no server; one fetched since a day, only when the search reaches back before it, or within a day of it). One connection per address, side by side (sioul_sync::search): each folder examined, read-only; UID SEARCH with the same conditions (FROM, TO, CC, SUBJECT, BODY, LARGER and SMALLER, SEEN, FLAGGED, ANSWERED, HEADER for List-Id, List-Unsubscribe, Precedence and Reply-To, NOT and OR; "Anywhere" as FROM, TO, CC, SUBJECT or BODY, never TEXT, which would find words in the servers' own lines; CHARSET UTF-8 and literals for words that are not ASCII, non-synchronizing where the server offers LITERAL+, RFC 7888); what is kept here left out by its UID; then the newest 200 fetched without marking them read (BODY.PEEK[HEADER], BODYSTRUCTURE, RFC822.SIZE, INTERNALDATE) and checked here on their headers and parts, for what a server cannot say exactly (a whole address, an attachment's name or kind, who the sender is to you; with "any of them", one of those asks the folder's newest whole). Their text, the server matched. A key may find more than its condition, never less, since what it finds is checked here: so "is not" a whole address or subject is left to the check here (a server finds words inside longer ones: jean-paul@ for paul@, "Re: …"), and so is "without attachments" outside Gmail (a list's footer makes a message multipart/mixed without any). The day it arrived is its Date, never later than it reached the server, as here (porch::sent): after a day, SINCE; before one, SENTBEFORE or BEFORE (mail moved from another server keeps an old Date and a new day of arrival); each a day wider than asked, since each server reads days by its own clock. Attachments: Gmail's own search where the server is Gmail (X-GM-RAW "has:attachment", filename:), elsewhere the headers most such messages wear (Content-Type: multipart/mixed, or multipart/signed), then the parts. Past the newest 200 of a folder, the line says how many more the server found, "up to": those are not checked yet. A server that refuses the words of a search is asked again without them, the days, sizes and marks only, and the newest checked here (imap_key_without_words): GreenMail, for one, reads FROM, TO and CC as whole addresses, where RFC 9051 finds parts of them, and answers BAD to a part of one ("@bank.example").
  • Merged without twins: a message kept here is never shown again from its server (same address, folder, UIDVALIDITY and UID), nor its copy in another folder of the same address (same Message-ID: Gmail's labels), the folder you sort from kept first (the inbox, your folders, the archive, Sent; "All Mail" last).
  • Said plainly: "Looking on the servers for the mail kept only there…", then "3 more on the servers." or "Nothing more on the servers."; a server that cannot be reached or refuses is named with why, the rest shown all the same. A row found there says "on the server"; opened, the message is brought into its folder here first, whole and checked as a sync brings it, still unread (search::bring), then reads as any. Moved, archived or deleted from the results, it is acted on by its UID, without being brought.
  • Sorting from the results is the search's main use: choose (Ctrl+A for all), then drag onto a folder or Move to…; the results follow at once, under one "Undo" for ten seconds.
  • On a demo profile, the servers are never asked.

Filters

Why, from the author's request of 7 October 2026: filters in a calm mail manager, not a dashboard, each field shown only once it needs setting; one flat list for every address, narrowed by address when wanted; carried by the settings to every device, and run on the inbox's unread mail only, so that the first device to fetch new mail does the work and no two devices race; a button runs them on mail already read.

Mail ▸ ⚙, under Filters. Code: crates/sioul-core/src/rules.rs (conditions, filters, what they do to one message), crates/sioul-sync/src/filters.rs (on the server: the mark, the acts, what waits), crates/sioul-app/src/filters.rs and qml/MailFilters.qml (the editor, "Try it", the run on the inboxes).

  • A filter is a name if you give one, on or off, the addresses it runs on (every one, or some), conditions (all of them needed, or one enough), actions, and whether the filters after it are still asked once it acts.
  • Conditions: a field, a test, a value. Fields: From, To, Cc, Reply-To, Subject, Text (an HTML message read as text), Anywhere (sender, recipients, subject or text), Attachment (there is one, none, one whose name contains), Kind of attachment (a PDF, a picture, a document, an archive, an invitation, a sound, a video, a text file), Sender (someone you know, safe, neutral, restricted, a stranger: who they are to you, porch.md), Newsletter or list (it is one, none, its List-Id contains), Day it arrived (before, after, between), Day of the week, Time it arrived (before, from, between, across midnight too), Size (larger, smaller than "5 MB"). Tests: contains, does not contain, is, is not. Text is compared as the search compares it: case and accents aside, each run of spaces one space; "is" takes a whole address or a whole name, any recipient's. A negative is its positive turned around, but a message that cannot tell (its size unknown, its sender not judged) holds neither way. A condition that does not read (a date written "1er octobre") never holds, and the editor says why.
  • Actions: move to a folder (named as you read it, "Banque", or as its server writes it, "INBOX.Banque"); archive (the address's archive found by its purpose, as the Archive button finds it: its Archive folder, Gmail's All Mail, else one made; so that one filter archives on every address whatever its server calls it); mark as spam ($Junk and the address's Junk folder, as the Junk button does, but with no line in the spam filter's label log: a filter is not your word on one message); flag; mark as read; move to the trash (never deleted for good); add a keyword (an IMAP keyword, which other mail programs show as a tag; never $Junk, $NotJunk or $SioulFiltered). The first filter that moves a message decides where it goes; reads, flags and keywords add up; a filter that stops the others ends the list there (rules::plan).
  • Stored in the configuration, one flat list for every address, in its order, each condition and action an inline table on its own line (config::set_filters, comments kept): toml [[mail.filter]] name = "Bank" accounts = ["home"] # absent: every address if = [ { field = "from", test = "contains", value = "@bank.example" }, ] then = [ { do = "move", folder = "Banque" }, { do = "read" }, ] stop = true # the filters after it are not asked # match = "any" # absent: every condition # enabled = false # absent: on Shared with your other devices as the rest of the configuration: the list is one entry, and the last change of it wins (database.md). A filter that does not parse is left out alone, never the whole file. One holding a field, a test or an action this Sioul does not know (a newer one wrote it, on another device) does not run here and says so in the editor; when this device writes the list again, that filter is written back exactly as it was, as long as it was not changed here (Filter::unknown), so that an older phone does not damage a newer computer's filters.
  • When they run: on each inbox's arrivals still unread, right after the fetch that brought them, on whichever device fetches first: the window (after the spam filter, on the watcher's thread), the phone's background step (steps.rs), sioul watch. Never on an account's first fetch (its last two weeks), never on mail already there: that is yours to ask (below). Never on what the Porch keeps from them: mail it sets aside (forged, blocked, a borrowed name, your provider's spam), hostile mail, what your spam filter keeps for review, and a code you asked for (rules::untouched). A sender nothing authenticates is read as nobody's: a From or Reply-To condition does not hold for it, and Sender reads "a stranger", as a project's route does not take it (porch.md).
  • No race between devices: before acting, a device marks each message $SioulFiltered (rules::FILTERED) on its server and acts only on what it marked. With CONDSTORE (RFC 7162), the mark is stored UNCHANGEDSINCE the mod-sequence read with its flags: of two devices fetching the same message in the same instant, one marks it, the other's store comes back in [MODIFIED], and that one leaves the message alone. Without CONDSTORE, the flags are read, then the mark set: two devices would have to read within the same round trip. A message read meanwhile, marked already, renumbered or gone from the inbox is left alone; a device that fetches later sees the mark, or no message. An act that fails (no such folder on that address, the server refusing) takes the mark off again, and the message waits for the next fetches here, three tries ten minutes apart at least, two days at most ($XDG_STATE_HOME/sioul/filters/waiting.toml: the account and the UID, never a word of the message), or for another device's. While something waits, every fetch runs the filters, new mail or not (filters::due). The window says why a filter failed in its status line, sioul watch in its output, a phone's background step in its log.
  • Servers without keywords (no \* in the inbox's PERMANENTFLAGS: Exchange, Outlook.com) cannot carry the mark. There each device acts on what it fetched while it is still unread on the server: a move, a flag or a read mark done twice leaves the message as once, and a message the first device moved is no longer in the inbox for the second. A filter whose only action is a keyword does nothing there, said once, and is not tried again.
  • Told or not: what a filter would move out of the inbox, put in the junk or the trash, or mark read is held back from the new-mail notifications until this device's filters have run (filters::untold, read by mailnote); the rest is told at once, as before. Then a message they acted on is not told, nor one read meanwhile or gone from the inbox, nor one another device marked first and is acting on. A message they could not act on (no such folder, the server refusing, no password, a renumbered inbox) is told as any new mail is, by the same rules of what reaches you (Done::to_tell, mailnote::unfiltered), on every device that fetched it; a retry that works later does not tell it again (the notifications' ledger). A message another device marked waits here too and is looked at again at the next fetch, which that device's act brings at once (it wakes the inbox's watcher): its mark gone and the message still there unread, this device acts on it itself, and tells it if it cannot; still marked ten minutes on, neither read nor gone (that device stopped half-way: closed, out of battery), this device finishes it: it takes the mark off, only if nothing changed the message since its flags were read (UNCHANGEDSINCE with CONDSTORE; of two devices doing so at once, one wins), marks it again as its own and acts, as for a message just arrived (Found::Stuck). So no message stays marked in the inbox with no filter ever acting on it. If that act fails, the message is told and tried again like any other; if the first device comes back and acts meanwhile, its act changes the message, and this device leaves it. sioul watch tells codes alone, which no filter touches. The Porch reads the inbox: a message moved out of it leaves the Porch too. Flagged or given a keyword, it stays and is told as usual.
  • The editor, calm: one line per filter, the rest when asked for. Each filter is one sentence ("From contains “@bank.example” → into “Banque”, marked read, and no other filter"), with its switch, and ▴ ▾ to ask it earlier or later; with several addresses, Show the filters of narrows the list to one, ▴ ▾ then moving a filter among those shown. A filter opens in place: its name, one condition and one action at first, Add a condition, Add an action; each value appears once its kind is chosen (words, a day, two days, a time, days of the week, a size, a kind of file, who the sender is); "every condition" or "one of them" once there are two; the addresses and "the filters below are not asked" folded under one line. Each change is saved at once, the sentence said again, and what keeps the filter from running said under it ("Choose the folder it moves messages into."). A folder only some of its addresses have is said ("This folder is only on …"). A deleted filter comes back with Undo for ten seconds. On a phone, the same in one column, the panel taking the whole screen.
  • Try it counts what the filter being edited takes among the newest 2,000 messages of the inboxes here, read or not, judged as the Porch judges them, and names three ("Banque des Berges: Votre relevé").
  • Run them on the inboxes… applies them to the messages of the inboxes here, read ones included. A dry run first says what would change ("In your inboxes now, 12 messages would change, read or not.", then each filter with its count); Run them now does it after ten seconds with Undo, each account in one connection, without the mark; what it moves leaves the lists meanwhile. Codes, mail set aside and the spam filter's review stay as they are (filters::preview, mail::schedule_filters).
  • A search made a filter: Make it a filter…, in the search's column, adds a filter at the end of the list with the search's conditions and opens its editor in Mail ▸ ⚙, to choose what it does. The search's address conditions become the addresses the filter runs on; a folder other than the inbox, and read, flagged or answered mail, are left out, and said above the editor (filters::new_from).
  • Not done: other folders than the inbox (no use seen yet); "keep out of the Porch" (moving to a folder does it); ordering by drag (▴ ▾ instead); forwarding, answering, running a program.

Unsubscribing

A newsletter or a list's message says how to leave it in List-Unsubscribe (RFC 2369). The Reader shows Unsubscribe above such a message, in the place of the old "Aa" (how text reads is in the page's ⚙). Code: crates/sioul-core/src/unsubscribe.rs (what is offered, the record), crates/sioul-sync/src/unsubscribe.rs (the POST), crates/sioul-app/src/mail.rs (the undo, the sending).

  • One click, three ways, the first that applies:
    1. One click (RFC 8058): List-Unsubscribe-Post: List-Unsubscribe=One-Click, an https: address, and a DKIM signature that passed (Sioul's own check at arrival, else the provider's) and covers both headers, signed by the domain in From or by the address's own. Sioul posts List-Unsubscribe=One-Click as a form: no cookie, no authorisation, nothing of yours, fifteen seconds at most; redirects followed only within the same site (none to another site); any 2xx is done. The signature is found by the results header's header.d, header.s and header.b; when several DKIM-Signature headers match, a header counts as covered only if all of them cover it. Exactly one List-Unsubscribe and at most one List-Unsubscribe-Post: a second one, added on the way, would be read first; then nothing is offered.
    2. A message (mailto:): one recipient, the subject and text the address asks for ("unsubscribe" where it gives none), never its cc or bcc; sent from the address the message came to, through the usual sending (sioul_sync::send::send), so a copy goes to Sent (Gmail files its own).
    3. A web page only: it opens in the browser at once, said plainly; Sioul cannot finish it there.
  • Never for mail set aside (forged, spam, a borrowed name, a blocked sender), hostile mail, mail in the junk folder, or when nothing proves the address is the list's own: the message must be verified (its From domain vouches for it), or a valid signature covering List-Unsubscribe must belong to the domain of the address it names (a mailing list's own signature). A web address must be a name on the Internet: no IP address, no local name (localhost, .lan, .local, .internal), no "name@" before the host. The button then rests, dimmed; its tip, and a click, say why.
  • Ten seconds to undo, as for sending: "Unsubscribing from Type & Pixels in ten seconds." with Undo in the status line; then "Unsubscribed from Type & Pixels.", or why not ("its server answered 404", "its server sent Sioul to another site", "did not answer within 15 seconds"). A second click while it waits asks nothing twice. The window closing does what waits, as for sending.
  • The message stays where it is: unsubscribing is about the next ones. Archive or delete it as any other.
  • Kept on this device: $XDG_STATE_HOME/sioul/unsubscribed.toml, one entry per list (its List-Id, else the sender's address), with its name, the sender, the way and when. A list left shows Unsubscribed, resting, its tip saying when and how, and that blocking the sender is the answer if its mail keeps coming; a page opened is said, and can be opened again. The Mail page's ⚙ lists them under "Lists you left". Not shared with your other devices.
  • The demo profile sends nothing: the click says so.

Where it stands

Done folders by special use or name, fetched in the background; the list of the last two weeks with "Earlier", search by sender, recipient and subject; the search by conditions (the filters' own), here and on the servers, merged; mail filters on the server, by the first device to fetch new mail, and on the inboxes when asked; dragging any number of messages onto a folder, moved in one connection per account; the reader with reply, reply to all, forward, archive, delete (for good in the trash), junk and not junk; read, unread, flag, move and the source one right click away; ten seconds to undo, also for sending and deleting a draft; leaving a list from a message, in one click (RFC 8058), by a message or on its page, with ten seconds to undo; the writing window with Cc and Bcc folded, attachments by button or drop, the Markdown preview, the sending account when there are several; drafts from other applications (Android's share sheet, mailto: links on Android and Linux)
Next emptying the trash and the junk; copying to a folder; saving as .eml; a message's thread; drafts on the server

Contacts (CardDAV, RFC 6352)

Feature Tier How
The list, with search in view names only, the search field on top
A contact: name, e-mail addresses, phone numbers in view
Write to, call in view next to each address and number
Postal address, organisation and role, birthday, notes, web sites, other fields one step folded under "More"
Add, edit in view "New contact"; editing in place
Categories (Nextcloud's groups) in view quiet chips under the name, one showing the list of that category; in the form, taken off with ×, added from those in use or typed; the list filtered by one, "All categories" first
Delete one step right click
Duplicates: on one card, between two cards one step "Duplicates" above the list, in the place of a card: cards cleaned with one click, pairs merged one at a time, every change undone with one click for thirty days
Address books (several per account) one step named on each card; a contact moved to another in one choice, asked first when it would lose something there
Photos changed here later

From a message, a sender becomes a contact in one right click, and a contact's page lists the mail exchanged.

Categories and duplicates

  • Categories are vCard's CATEGORIES, what Nextcloud Contacts shows as groups. Every line of a card is read (several are merged; an escaped comma, A\,B, stays inside a name), each name once, compared trimmed, case and accents aside (contacts::category_key, as the sender lists compare them, porch.md), kept as the card writes it. They are written back only when changed, then as one line where the first was; a card saved by what does not give its categories (an import, a sender added) keeps them as they are.
  • A person's sheet, from any card or a message's sender (PersonSheet.qml, its words from reaches.rs): "How Camille reaches you": their list and why (their own choice, a category, or their address book), the list chosen for them, all their addresses and numbers, those added later too (the card's UID on that list, contact:<UID>; their addresses' and numbers' own entries taken out), Always through, and a sentence per channel of what reaches you from them at each time; an address or number of theirs that keeps a list of its own is said, and comes first (attention.md, "The interface"). It replaced the card's "Their list" and a message's "Their mail". The choices and the sentences are the lists' (senders::person_json), the sheet only shows them.
  • Phone numbers are compared, never rewritten (phones.rs): each gets a key, its international form with its extension. Spaces, dots, dashes, slashes and parentheses aside; "00", or the country's own prefix for abroad ("011" in North America), read as "+"; a trunk 0 kept after a country code ("+33 (0)6…", "+33 06…") dropped, Italy's 0 kept; a national number with its trunk prefix given the code of the country chosen in the Contacts settings ([contacts] region), else of the system's locale (LC_ALL, LC_TELEPHONE, LANG), else of Sioul's language; extensions kept (;ext=, "ext.", "poste", "x", "#", ","); short codes ("3631"), numbers with letters, stars or hashes compared as written. The countries known: France with its overseas departments (Réunion and Mayotte, Guadeloupe, Martinique, French Guiana: the same ten-digit plan, each its own code), Belgium, Switzerland, Luxembourg, Germany, Austria, the Netherlands, the United Kingdom, Ireland, Spain, Portugal, Italy, Denmark, Norway, Sweden, Finland, Poland, Greece, the United States, Canada, Australia, New Zealand, Japan, India, Morocco, Algeria, Tunisia, Senegal, Côte d'Ivoire. Elsewhere, numbers without their country are compared as written. libphonenumber knows every country, for megabytes of data: not for a comparison. E-mail addresses compare case aside.
  • On one card (duplicates::clean): the same number or the same e-mail address written twice. The line kept is the one written internationally, else the one saying most of what it is for; it takes the kinds and the "use first" of the lines that go, and its value is never rewritten; an Apple label (item1.X-ABLabel) goes with its line, or to the kept line when that one has none. REV renewed, one write per card, read-only cards left out.
  • Two cards (duplicates::pairs): the same name (case, accents, punctuation and the order of words aside: "DUPONT, Jean" is "Jean Dupont"), the same number or the same e-mail address. What more than four cards share says nothing of one person (a switchboard, an office). Read-only cards and cards waiting for their deletion are left out; pairs come one at a time, those sharing most first. "Not the same" is remembered on this device, by UID, in ~/.local/state/sioul/contacts-not-the-same.toml.
  • Merging (duplicates::merge): the card whose name is kept stays line for line; the other adds its numbers and addresses (then cleaned as on one card), postal addresses, web sites and chat addresses unless the same, its categories (one line), its notes after a blank line unless one holds the other, and what the first lacks of one value (organisation, role, birthday, photo…); Apple's groups get free names; a vCard 4.0 line going into a 3.0 card is written as 3.0 (as_vcard3). The other card is deleted; the sync sends both changes (PUT with If-Match, DELETE).
  • Undo: before any change, the cards as they were are written to ~/.local/state/sioul/contacts-undo/<time>-<clean|merge>.json, kept thirty days. "Undo" writes them back: a card deleted comes back under its file name and the sync sends it again; a version changed meanwhile (a sync, an edit) is set aside beside the record first. Address books kept on this device only work the same, without the sync.

Calendars (CalDAV, RFC 4791)

Feature Tier How
What comes: what is left of today, then the next two weeks in view the default view, a calm list; from today it starts now, an event already over left out (pim.rs, upcoming), the day and the week still showing it
Week and month one step a switch above the list
Titles read whole, in the day and the week in view the hours fill the height and grow past it, scrolled, until each event's title reads whole in its card, measured at the card's width: on a phone's week the cards are narrow and titles take several lines. An hour is 160 px at most; past it, the shortest cards end their title with "…". The time goes under the title when a line is left, its start alone in a narrow card; the hours' column is narrower on a phone (WeekPlanning.qml)
Now, and today in view a quiet line at now in the day and the week, today's column and title marked, today's cell in the month; they follow the window's one clock (qml/Clock.qml): the line at each minute's turn and at once when the window or Sioul comes back, today's marks at midnight; the agenda is read again at each five minutes' turn, when it shows again and when the day turns (AgendaPage.qml, WeekPlanning.qml)
An event: title, when, where in view
Notes, reminders, recurrence, guests, the calendar it belongs to one step folded under "More"
New event in view "New event"; all-day in one tick
Edit, delete, duplicate, move to another calendar one step right click
Moved by hand, in the day and the week in view an event dragged to another time keeps its length, in the week to another day; by its bottom edge its end changes. Where it goes is drawn with its times ("Tue 6, 11:15–12:15"), by whole five minutes, within the hours shown (further: its form); let go, it is written as its form writes it, line by line (agenda::moved), sent, and "Undo" waits ten seconds, putting back what the file held unless something changed it since. A repeating one asks first: Only this time (a changed occurrence, RECURRENCE-ID, RFC 5545 §3.8.4.4; one changed already is changed again) or Every time (its first time, the times left out and added, and those its changed occurrences replace move by as much on the clock, a weekly rule's days with them; a rule that sets its days otherwise, "the first Monday", says to move only this time or to change it in its form). A calendar that can only be read, and whole days, stay. With a mouse, press and drag; with a finger, a long press picks it up, a plain swipe scrolls. Two events at once afterwards: said above the agenda, as any (below). The plan goes around it at once (WeekPlanning.qml, TimeDrag.qml)
Tasks pinned to a time: their time blocks in view an event per block, in « Planned tasks » unless Tasks ⚙ names another calendar: the task's title, busy, X-SIOUL-TASK and an RFC 9253 link to the task, which links back (tasks.md, "Pinned to a time"). Moved like any event (here, by a drag; elsewhere, any application), the task follows; deleted, the plan places the task again. No alarm unless asked: Sioul reminds of it as of any event. Never counted as an event besides its task, in the plan, the day or the record
Invitations by mail (.ics, iMIP): add, accept, decline in view in the message that brings them
Several calendars, their colours one step a filter, folded
Time kept before and after an event (getting there, getting ready, coming back), what it costs and gives back one step under "More"; kept free in the plan, shown apart in the day (tasks.md)
Two events at once, or closer than a time you set in view at once only when their times overlap; no time is put between events but what you set: an event's own Before and After (in its form, said in its details) and Settings ▸ Agenda, Time kept between two events (agenda.between, 0 at first), the longest, never added; closer than that, said too close with that time and where it is set, never an overlap (22 October 2026, overlaps::tests::twenty_minutes_apart_never_overlap_whatever_their_form). The plan's five-minute pause around events (plan::PAUSE) lays tasks, and is never said between events; today's on the Porch, the next two weeks' above the agenda; each said until you open one to move it or say "Don't mention it again" (kept on this device, overlaps-set-aside.toml); an event moved is a new question
Tasks (VTODO) with their links (RFC 9253) in view their own place, "Tasks" (tasks.md)

Recurrences are expanded for display (RFC 5545 §3.3.10); times are kept with their time zone and shown in yours. calcard expands a series from its start, so each stretch asked for is expanded from just before it (agenda::from_window): the series' start moved forward by whole periods of its rule, on its own clock, which gives the same occurrences from there on, for rules repeating by the second, minute, hour, day or week, without COUNT and without a change to "this and those after" (RANGE); an event repeating every hour for years still reaches today. The cap of 20,000 occurrences a file stays as a safety net. A changed occurrence is matched to its time in the series by its RECURRENCE-ID alone, as RFC 5545 says: calcard also asks both to have the same SEQUENCE, so a series edited since, or an occurrence changed by an application that counts its own changes, would show twice; each is read with the series' (agenda::for_expansion), the file left as it is. Leaving out one occurrence that was changed takes its changed copy out, and leaves its time in the series out (EXDATE).

Storage and sync

  • Contacts and calendars: one .vcf or .ics file per item, one folder per address book or calendar (vdir, as pimsync and khal use), with the server's ETags kept apart in the state folder; changes go back with If-Match, so nobody's edit is overwritten silently. When both sides changed one item, the server's version is kept and yours is set aside among this device's earlier versions (Settings ▸ Your folder and sharing ▸ Show earlier versions, under "Your calendars and contacts", named by the item's title), said in the status line, to put back from the window, on a phone too; put back, it goes to its server over the server's version (history::ACCOUNTS, database.md). Until 10 October 2026 it went to ~/.local/state/sioul/dav/conflicts, which no file manager reaches on a phone.
  • Discovery: the address you give, else .well-known/carddav and /caldav (RFC 6764) on your domain, then a few providers' known places (Murena, Fastmail, iCloud, Posteo, mailbox.org) and port 2080 of your mail server for cPanel hosts; then the principal and its home sets. Redirections are followed within the same domain only (caldav.example.org from example.org), since each request carries your password: a domain whose calendars live with another provider needs that server's address given. The principal and the home sets a server names keep to that domain too.
  • Your password goes to your server only: once an account's homes are known, each request goes only to their servers, by origin (scheme, host and port, dav::origin). An address book, a calendar or an item a server's answer names on another server is never asked, with or without your password: that one is refused, the others sync, and the sync says so ("the server named an address on another server (…); Sioul did not go there, and sent no password", SyncError::Elsewhere). A hostile or broken server could otherwise have sent your password wherever it named. The folder of files that sharing uses on a Nextcloud keeps to the server its calendars were found on, for the same reason (remote::files_root). Tested against two stand-in servers, one naming the other, which notes every request it gets: it gets none (dav::tests::the_login_stays_on_the_account_s_server, remote::tests::the_files_stay_on_the_account_s_server).
  • Google: signed in on Google's page with your own key (OAuth, no password), calendars and contacts over its CalDAV and CardDAV, tasks over Google Tasks; what Google does not keep shows greyed (google.md).
  • Moving a task to another list or a contact to another address book, across accounts too: written there, then taken out here; when the place would not keep something it uses (Google), Sioul says what before moving.
  • Sync: what changed here goes first (PUT with If-Match, new items with If-None-Match: *, DELETE), then what changed there, with the sync token when the server keeps one (RFC 6578), else by comparing ETags; changed items come back in batches (multiget) of 50 at most, half as many after a slow or cut answer, twice as many again after quick ones. An invitation answered on two devices meets its UID on the server at the second: the item there takes its place (said when its answer differs). Each wait for the network is bounded by Sioul's own layer on ureq's transport (sioul_sync::stalls, the one place built on ureq::unversioned, which follows no semver: written for ureq 3.4). Every fifteen minutes in the background, and at once after a change in the window.
  • An event moved to another calendar (21 October 2026): its editor says its calendar and moves it to another writable one of any account, this device's own to a server's above all, so that an event filed there by mistake reaches your other devices (pim::move_event_file, vdir::move_item): the move noted first (state/dav/moves.toml, under its lock, .moves.toml.lock: one move or finishing at a time, in a program and between programs, since 22 October 2026, when CI's Windows job saw another test finish a move half made), the event written there, taken out here, the note gone; a step cut short (a crash, Android stopping Sioul) is finished before the next move and before each sync (vdir::finish_moves): there and still here, taken out here; not there, kept here. Never twice, never lost (vdir::tests::a_move_cut_at_each_step_ends_once); an item made another in its own place (a plan's task become its event, one collection, one name) is never taken out (an_item_made_another_in_its_own_place_stays). The sync sends the new item first and deletes the old copy on its server only once the new one is there (state/dav/moved.toml, vdir::deletion_waits_for; dav::tests::a_moved_event_is_deleted_there_only_once_its_new_copy_is): a refused PUT leaves the old copy on every other device, and the new item is never replaced by the old one's UID. A meeting with others (an organizer or guests) never moves: its server would cancel it for them, then invite them again; its editor says why (pim::with_others). Another device that changed the old item meanwhile gets a 412 for its deletion: the old item comes back beside the new one, doubled, never lost (review of 22 October 2026, F9). New events go into (agenda.calendar, Settings ▸ Agenda, agenda::default_calendar): there was no such setting before, a new event going into the first calendar that takes events, which stays the first value. Which calendars stay on one device: this device's own (local) while your sharing does not carry them (none set up, or its part "lists" off here); their events' editor says "Only on this computer" (pim::here_only). A server's calendar is on each device that has its account.
  • Every collection at once (the phone's calendars of 21 October 2026: only the first calendar was made while its first pull lasted): every calendar and address book the server lists is made here right after the listing, its folder, name, colour and state, before any is pulled (dav::register), so that each is listed, can be written to and is reached at once, empty until pulled. Then they are pulled in turns, a slice of twenty seconds each (dav::SLICE), a batch at least, until all are whole: a big calendar never keeps the others waiting, and a pull cut short (Android stopping Sioul) goes on from its last batch. The calendars go whole before the address books, which never hold them back. An error in one collection, a lasting one too (a calendar shared with you that refuses your login), ends that collection alone (Report::problems); only a home refused ends the account. The account's status says how many were found and how many synced whole: "Contacts and calendars synced at 09:30: 3 of 3 found.", or "1 of the 3 calendars and address books found synced." with the first problem.
  • Editing keeps what Sioul does not show: cards and events are changed line by line, so a photo, an alarm, a guest list or another application's fields come back exactly as they were. An event's time and repeat rule are rewritten only when you changed them: an event in another time zone keeps its zone, a rule the form cannot show ("every other Monday and Wednesday") stays whole.
  • Libraries: calcard (Apache-2.0 or MIT, Stalwart) reads vCard and iCalendar and expands repeating events (RRULE, EXDATE, changed occurrences, time zones); the protocol is a small client of Sioul's own on ureq and roxmltree, since the handful of requests needed did not justify an asynchronous HTTP stack.

Where it stands

Done accounts found from the address or a server address; address books and calendars listed and synced both ways; contacts: the list with search and categories, the card (categories, addresses with "Write", numbers with "Call", the rest folded), editing in place, new contacts, deleting with "Undo", completion of addresses when writing, a sender made a contact in one click, duplicates cleaned and merged with "Undo"; agenda: today then two weeks, the week, the month, an event's details with notes and guests folded, new events (whole days, repeating ones), editing, deleting this time or every time with "Undo"; invitations in mail: accept, maybe, decline (the answer goes to the organizer, iMIP), add a published event, take a cancelled one out
Next moving an event to another calendar; changing one occurrence only; changing a contact's photo here (the window shows them already); a calendar's colour chosen here

PGP (RFC 9580, PGP/MIME RFC 3156)

  • Sequoia (sequoia-openpgp, LGPL-2.0-or-later), with its pure-Rust cryptography, so it builds the same on Windows.
  • Your keys live in Sioul's store; their passphrase in the system keyring, so nothing is asked at each message. Keys come in by import (a file, or GnuPG's export), or are made by Sioul.
  • Others' keys: from their messages (Autocrypt headers, attached keys), from their contact card, from the Web Key Directory of their domain and keys.openpgp.org.
  • Reading: an encrypted message is decrypted on opening; a signature is checked and said next to the sender's shield (signed by whom, valid or not).
  • Writing: two small toggles, sign and encrypt; encrypt is offered only when every recipient's key is known, and says whose key is missing otherwise.
  • In view: the two toggles and the reader's line. One step: key details, fingerprints, import and export, in Accounts under "Encryption".

As built

  • Sending: PGP/MIME (RFC 3156). Signed alone: multipart/signed, the text parts in quoted-printable so trailing spaces (the "-- " line) and long lines arrive as signed. Encrypted: multipart/encrypted, signed inside, to every recipient's key and yours (to read it again in Sent). The subject stays readable, as in most clients today. An encrypted message names every key it was encrypted to, the Bcc recipients' too, and anyone can look a key up on a key server: so every recipient can see that a key was added for someone, and which. With "Encrypt" on and recipients in Bcc, the writing window says so before Send (DraftProtection::bcc_seen); a message of its own for each Bcc recipient is for a later round.
  • Reading: PGP/MIME encrypted or signed, and inline PGP; a decrypted message lives only in memory, its attachments decrypted again when opened. The line under the sender says "Encrypted · Signed by …", "Signed with a key Sioul does not know", or "The signature does not match the text", in warm colours, never red. Answering an encrypted message switches "Encrypt" on when every key is known; its copy beside the draft stays encrypted, opened only at Send (below, "Encrypted messages you answer").
  • Keys: "Make a key for …" in Accounts makes a Curve25519 key valid three years, with a random passphrase the keyring keeps, and keeps the revocation certificate Sequoia makes with it beside it: ~/.local/share/sioul/pgp/own/<fingerprint>.rev ($XDG_DATA_HOME/sioul/pgp/own/, the data folder's pgp/own/ on Windows and macOS), readable by you alone as the key is, never shared between devices, moved aside with the key to pgp/removed/ when the key is removed (pgp::revocation_path). As GnuPG writes its own (openpgp-revocs.d), the file says what it is, then the certificate armored with a colon before its first dashes, so that it cannot be imported by mistake. "Save the revocation certificate", beside the key, copies it to your downloads, to keep somewhere safe apart from the device; sioul pgp make prints its path after the fingerprint. Revoking is not built: once it is, the certificate is what revokes a key that is lost. A key imported or on a security key has no certificate here: its own tools made it; "Import a key…" takes GnuPG's export (gpg --export-secret-keys --armor you@example.org), asks its passphrase once and keeps it in the keyring; "Save the public key" writes it to your downloads. Others' keys come from their messages (Autocrypt, only when the message is not forged), from a file, or from "Look for their keys" in the writing window (their domain's Web Key Directory, then keys.openpgp.org: only on request, since it tells a server to whom you write). Your outgoing mail carries your key in an Autocrypt header.
  • Which key is whose (review of 19 October 2026, F1 and F2): a user ID counts only when Sequoia's standard policy sees it bound to its key and not revoked (pgp::bound): one written into a certificate without its key's signature names nothing. A certificate revoked as a whole serves nothing (pgp::usable, pgp::names; review of 19 October 2026, N3): Sequoia's test of a key's own revocation misses the certificate's, so a certificate revoked as compromised used to keep its live subkeys, and the newest-key rule below picked it. It is never chosen for an address, never encrypted to (pgp::encrypt passes it over whoever chose it), never takes a seal or a copy of your own, never signs; it still opens what was sent to it before. It is still kept when it comes (a header, a lookup), so that its revocation reaches the copy kept and stops it; the writing window says your own was revoked, and what to do: make a new key, or read your new security key (pgp-self-copy-revoked). A certificate revoked by its designated revoker (a Revocation Key, GnuPG's addrevoker) counts as revoked once that revocation checks against the revoker's certificate kept here, yours or someone's (Keys::usable; R2): Sequoia says such a certificate "could be" revoked and leaves the check to whoever holds the revoker's key; a revocation that does not check is passed over, since anyone can attach one, and with the revoker's certificate not kept here nothing checks it. A signature by such a key reads "Signed with a key that was revoked" (pgp-signed-revoked). A key that came from elsewhere keeps only the user ID of the address it came for (pgp::for_its_address): from an Autocrypt header, the one its addr names; from a lookup, the one asked for. An Autocrypt header carrying one of your certificates is passed over (learn_autocrypt_at), your security key's too on a device that knows it from the sharing alone (your own mail read on the phone: pgp::yours_at), and a lookup that brings one of yours again adds to it only what your own primary key binds (a renewal's signatures, a subkey), never another source's user IDs (store_from_elsewhere_at). Your addresses are those of your accounts and those your security keys name (Keys::is_own_address), never what a certificate's user IDs claim. When two keys of someone else's claim one address (a key made anew, the old one kept), the one whose newest live encryption key is the most recent serves, then the one whose primary key is, then the fingerprint's order: never the order the folder lists them in (Keys::for_address).
  • What others' keys serve (review of 19 October 2026, N2): beside each key in pgp/others/ a small file says the addresses it was learned for, the only ones it serves (<fingerprint>.toml, format 2, travelling with it in the senders' part): an Autocrypt header's address or a lookup's, or, for a key you import from a file, every address bound to it, since you chose the file. A key that comes again for another address serves both; its file keeps only those addresses' user IDs (pgp::store_others), so another address it names never serves, nor shows in Accounts. Keys kept before, or written through the sharing by an older Sioul, have no such file: each device writes it once for them, as it judges them, saying those addresses were judged rather than learned (judged, for a later rule to look at again; pgp::keep_others_for_their_addresses, marked in state/pgp-others.toml, run with the look below), and judges at each reading one that has none (an older Sioul's write arriving). A key is judged by every user ID it carries, bound or not, revoked or not (pgp::judged), since a Sioul before 1f9acc0 took a key on any of them: a header's key had to name its sender in some user ID, bound by nothing if it liked. It serves its one address only when every user ID names that same address and it is bound; else none. So a key naming several addresses serves none, since which one it came for was never recorded and a key's owner can bind any address to it (Mallory's key naming Jane's address served it, the newest-key rule choosing it every time); and so does a key whose one bound address is not the one it came for: Mallory's key binding Jane's address and carrying his own in a user ID bound by nothing, which his own header made pass (review of 19 October 2026, R1: taken by its one bound address, it served Jane's, and the first rule of a18e58f would have written that down on every device). It stays on the disk, never deleted, and serves again as soon as it comes for an address: its owner's next message (Autocrypt) or Look for their keys; meanwhile the writing window says the key is missing, and its signatures read as a key Sioul does not know. Written so, a key an older Sioul later merges with every user ID of a header still serves only what its file says. A lookup that brings one of your own certificates adds only what your own key binds: a subkey bound by nothing, or by another key, is left out (only_bound_subkeys). Tested: pgp::tests::an_autocrypt_header_never_makes_another_address_yours, a_senders_key_naming_janes_address_never_becomes_janes, a_key_kept_before_serves_no_address_it_was_not_learned_for, a_key_kept_before_bound_to_one_address_it_did_not_come_for_serves_none, others_keys_say_what_they_serve_and_an_older_write_adds_none, a_revoked_certificate_never_serves, a_designated_revokers_revocation_is_honoured_once_it_checks, a_lookup_bringing_yours_adds_only_what_your_key_signed.
  • Tested both ways with GnuPG 2.4: Sioul's signed and encrypted message decrypts in GnuPG with "Good signature"; GnuPG's encrypted, signed and tampered messages read as such in Sioul.
  • Next: subject protection (protected headers), revoking (with the certificate kept) and extending a key, trusting keys explicitly.

Encrypted messages you answer

The copy of the message a draft answers or forwards, kept beside it (drafts/<id>.eml, Draft::keep_original) so that the answer can quote it once the message is archived or deleted, stays as it came: encrypted when it came so. The draft says it was kept so (Draft::copy_as_received). Until 19 October 2026 Sioul wrote the decrypted text there (crypto::protect_answer): it then sat in clear on the disk, the sharing carried it to every device (sealed in the sharing folder, in clear on each device) and kept its earlier versions there. What you type in an answer is kept as any draft is, in clear on each of your devices and sealed in the sharing: encrypting a draft's own words would make each draft ask for your key, and a phone could not open them.

  • At Send the copy is opened in memory only (send::build_draft): with the session key kept since you opened the message (until Sioul closes), with your key here, or with your security key in the same access that signs: one PIN. A key whose touch setting asks for each use asks two touches, one to open and one to sign. A message encrypted to another security key than the one that signs is opened first with that key and its PIN, its session key then kept, and the answer is signed after (securitykey::open_quote_now). The quote goes in the encrypted answer, to the recipients and to you; nothing of it is written anywhere.
  • Not encrypted, an answer goes without its quote, and a forward does not go: an encrypted message's text never leaves in clear. The writing window says so under the boxes as soon as "Encrypt" is unticked (DraftProtection::sealed_quote); Send refuses such a forward at once (securitykey::forward_refused), never at the end of the ten seconds.
  • Where the copy does not open the writing window says so under the text, and its copy stays encrypted: on a device that cannot use your security key (a phone, for now) an answer sent from there goes encrypted without the quote, and a forward only from a device that opens it; when no key of yours known on the device opens it (encrypted to a key you no longer have, to one kept on another device alone, or to others alone), the window says that, sent from there, the answer goes without its quote, and that it cannot be forwarded from there; Send refuses such a forward at once with the same words (crypto::Answered::Elsewhere, NoKey). The window never says the message "came encrypted": a copy the look sealed (below) may be of one that came in clear.
  • Copies written in clear before (sioul_core::draftcopies, crypto::seal_clear_drafts) are encrypted again to your own certificates (your security key's, as your computer read it, and your keys here; an encryption key of yours that expired, not revoked, still takes them: you open them with it), on every device that holds them: beside the drafts, among the sharing's earlier versions (share/history/drafts/) and in what was kept before sharing began (share/before-sharing-<day>/). In doubt, a copy is sealed, judged by where it comes from, never by its shape alone (review of 19 October 2026, N7). One kept as it came (its draft says so) is passed over. Any other was written before 19 October: the message itself when it came in clear, its decrypted text when it came encrypted. It is sealed unless it is, byte for byte, a message found in your mail on that device under its Message-ID or the one its draft answers (Draft::in_reply_to), every message of those IDs there having come in clear (the mail index keeps every file of an ID: MailIndex::paths): a copy of a message found nowhere, or of one that another message shares its ID with (anyone who knows an ID can send one), is sealed, whatever mailer wrote it. (Until 19 October a copy whose head was not one Sioul rebuilt was taken for a message as it came; but a decrypted entity with an empty head, or none, rebuilt so has exactly that shape.) A sealed copy keeps outside only the outer headers its message came with; everything after the MIME-Version: 1.0 line Sioul added goes inside as it was: protected headers (the subject its sender encrypted), or an entity's first line when it had no head, which would else read as a header (pgp::seal_message). What a look decides is kept with the file as it read it (the stamp of the bytes read, from their descriptor; for a copy it sealed, the new file as written), never with what the path names afterwards: a version an exchange puts in place meanwhile is looked at again (N6). A copy that only quotes an armoured block is not taken for sealed (pgp::is_encrypted: the whole message, or its whole first text part, one block). The draft's own file, its text and its choices, is never touched, and a copy is replaced whole, never missing: no draft is lost, and a draft edited on two devices meanwhile keeps both versions, as any draft does. An encrypted copy whose draft went (sent while the look replaced it) is taken out a day later.
  • When the look runs (it first writes, once, what others' keys kept before serve: above, "What others' keys serve"): at the window's start, in its own thread once the environment is set, before each of the window's exchanges, before it takes the hand that a dose's alarm waits for (BUSY), and at a phone's background step, after its exchange; never on a dose's alarm, at "Taken" nor at a waking, where Android gives seconds (review of 19 October 2026, F9). Its verdicts are kept on the disk (state/draftcopies.json, never shared: each file's path, its device's and inode's numbers, its size and its time), so that a process started for a moment reads only what changed, and your keys are read only when a copy is to be sealed. Measured in a fresh process, "Taken"'s exchange with 150 copies of 100 KB pending in the history and 300 others' keys took as long as with the keys alone (9 to 26 ms; 6 to 17 ms with nothing kept): the copies cost it nothing (share::tests::measure_the_alarm_path_with_a_look_pending). A device with no certificate of yours that takes a seal writes nothing and looks again later; Accounts ▸ Encryption says in one quiet line how many copies wait, with nothing to press. The log says counts only. Tested: draftcopies::tests, share::drafts_tests::clear_copies_are_encrypted_on_every_device_and_none_stays, send::tests::an_encrypted_message_answered_opens_at_send_with_the_key_that_signs, send::tests::a_key_here_opens_the_copy_and_a_stranger_s_does_not, share::drafts_tests::a_crash_s_leftovers_among_the_drafts_go_an_hour_later, filelock::tests::a_read_only_copy_is_overwritten_too, draftcopies::tests::a_decrypted_copy_without_headers_of_its_own_is_sealed, a_copy_replaced_while_the_look_reads_the_mail_is_looked_at_again, a_copy_replaced_as_soon_as_the_look_lets_go_is_looked_at_again, crypto::tests::the_look_leaves_no_clear_copy_and_says_counts_only, the_look_is_off_the_doses_path, a_draft_opened_without_the_key_says_so_and_stays_encrypted, securitykey::tests::a_forward_that_cannot_go_is_refused_at_send, pgp::tests::a_sealed_copy_keeps_the_protected_headers_inside.

What a disk keeps

A copy Sioul encrypts again itself is written beside the old one, under a hidden name the sharing passes over, and onto the disk (filelock::Prepared); then, under the sharing's exchange lock, it is renamed over the old one if that is still the file that was read (Prepared::place), so that its path always holds the old copy or the new one; then, the lock let go, the old file, held open across the rename and now without a name, is overwritten with zeros and flushed before it goes (Placed::finish; a copy that is yours but read-only is made writable first). The lock is taken a short hold at a time, a fifth of a second at most, for the renames alone (and the removal of an orphan copy, above), so that an exchange waiting for it (a dose's) never waits longer for it; and since the look runs neither on a dose's alarm nor while the window's exchange holds the hand a dose's alarm waits for, nothing else of it delays a dose. An exchange reads these files only under that lock and by their names, so it never holds a copy being replaced: it never reads, seals or sends the zeros. Every other reader (Send, the writing window, the sharing's own reads and copies as a last guard) reads through filelock::read_whole or copy_whole: the file read through its descriptor, then checked to still have a name and to be the one its path names, else read again, eight times at most; what the sharing records of a file (its size, its time, its hash) is that of the bytes so read. On 19 October 2026 the final pass caught a plain read getting the zeros (filelock::tests::a_file_replaced_between_its_opening_and_its_reading_is_read_again makes it happen; share::drafts_tests::no_copy_is_replaced_while_an_exchange_holds_the_lock, exchanges_running_while_copies_are_replaced_never_send_zeros). What a crash leaves beside a file (its hidden temporaries) is swept once it is an hour old, at the sharing's daily tidy, in the folders that travel whole too. That is as far as a program can go, and it covers the copy Sioul replaces itself. On ext4 or XFS on a hard disk, the zeros land where the text was. The earlier copies the sharing replaced, on each device it reached (another device's encrypted copy written in place of a clear one, by a plain rename), the versions pruned before, the temporaries of a version received or put back, and an old copy whose overwrite a crash cut short, are freed as any deleted file is, never overwritten: the disk's encryption is what covers them. A filesystem that writes each change elsewhere (Btrfs, Fedora's default; ZFS; APFS on a Mac; F2FS on many phones), and any SSD or flash memory, which moves its blocks on its own, keep the old blocks until they are reused; snapshots (Btrfs's, Time Machine's) and backups keep them as long as they last. Only the disk's own encryption makes those leftovers unreadable to someone who holds the disk: LUKS on Linux, FileVault on a Mac, BitLocker on Windows; Android encrypts a phone's storage itself. On Windows nothing is overwritten: the copy is replaced, its old blocks freed, and no reader there can meet zeros. The sharing folder never held the text in clear, but it and its server keep the old copies, sealed with the sharing's key, until those rounds go (a device's round goes once it has written a megabyte past it and every device read past it) and the server forgets them (its versions, its trash bin, a sync app's own versioning, backups of the folder): anyone holding the sharing's passphrase could open them meanwhile.

Your keys on a security key

An OpenPGP card (a YubiKey, a Nitrokey: OpenPGP card specification 3.4) holds the private keys and never gives them out. Sioul hands it a digest to sign or a wrapped session key to open, after its PIN, and the key may wait for a touch. Sioul keeps what is public, above all the certificate, which a card does not hold.

  • Where a key's private part is (pgp::Place): here (pgp/own, its passphrase in the keyring), or on a security key, listed in pgp/cards.toml with the fingerprints of its two keys, the certificate they belong to, the addresses it signs for, and where and when its certificate came; no secret. A security key read on this device that serves an address signs for it alone, and Autocrypt announces its certificate; a key kept here for the same address only opens older mail (Keys::own_for, Keys::card_for).
  • On every device (19 October 2026): what a computer read of the key and its certificate, public parts only, travel sealed to your other devices (data/pgp/cards/, one file per key: securitykey::share_cards); never pgp/own, never a private key, a PIN, a session key or an opened message. Written only by a computer that read the key itself: after every change of cards.toml, each time its certificate is kept again (a renewal, a new subkey), and at an exchange when the shared file lacks something the computer holds (F5: the file is merged, which only adds, so it settles), merged with the one shared already so that a renewal made on one computer is never undone by another. A key is shared for the first time once, by a mark kept on that computer and never shared (state/security-keys.toml, securitykey::share_cards_at_exchange): a file another computer took out when it stopped the key is never written again by the next exchange; only reading the key again, or keeping its certificate again, writes it again (F4). Stop using this security key stops it on that device: it goes from cards.toml there and from the sharing (securitykey::forget_in), and a list kept there, never shared (pgp/stopped.toml), makes that device pass over what another computer shares of it again (Keys::load_at); reading the key there again takes it off the list. The other computers that read it keep using it and may share it again (a renewal there), and the words say so: stop it there too (seckey-stopped, seckey-stopped-shared, which also says how to take it back on a computer: read it there). A key another device read is not offered to stop on a phone (review of 19 October 2026, N5): a phone with neither NFC nor a USB host cannot read a security key, so nothing there could take it back, and stopping it there would only stop the phone encrypting your own copies (a phone with either reads it again, held against it or plugged in, and may stop it); stopped on a computer that read it, it leaves the phone too (securitykey::may_stop; pressed anyway, the phone says to stop it on a computer that read it). A device that receives the file counts that certificate as yours only when its primary key is the one the key's record names and its signing and decryption keys are bound to it, alive, as Sequoia's policy sees them (securitykey::vouched, through securitykey::check with the slots' fingerprints: F3); anything else is passed over. A device signs only with a key it read itself (F18): a key shared from another computer encrypts to you, gives Autocrypt your certificate and the Reader its words, and signs nowhere else; on a computer that did not read it, Accounts says "Read on another computer of yours. To sign with it here, plug it in and read it here.", and the writing window keeps "Sign" off and says so (seckey-sign-read-here). Accounts never offers "Make a key for" an address your security key serves, read here or elsewhere (N4): on a computer that did not read it, it says so under the keys, with Use a security key (crypto::offers, pgp-read-card-here); on a phone, nothing. The same offer stands on a computer that keeps a key of its own for that address: that key signs there meanwhile while Autocrypt hands out your security key's certificate, which a recipient cannot match to the signature until the key is read there. Autocrypt is not switched to the key kept here instead: correspondents would take whichever certificate came last, from this computer or another, and mail encrypted to the key kept here opens on this computer alone. "Renew for two years" is offered only for a key read on that device: renewing looks for the key among those read there. So your sent copy opens on each of your computers (and on a phone with NFC, the key held against it: below, "On a phone"); a phone encrypts to you as well as to the recipients and hands your key by Autocrypt; a phone with neither NFC nor a USB host says "Encrypted to your security key: open it on a computer for now", with no button, and its writing window keeps "Sign" off and says why. For your own addresses only your own certificate is used (Keys::for_address, Keys::self_cert_for): never one someone sent claiming them (an Autocrypt header, a lookup). Whenever a message goes encrypted without a copy for you, the writing window says so before you send (crypto::self_copy_line): which part of your certificate cannot take it (its encryption subkey expired, with its date, or revoked: securitykey::expired_parts, revoked_encryption) and what can renew it (N8): your security key's certificate, Renew for two years in Accounts on a computer (on a phone, "on one of your computers"); a key kept here, only the program that made it, or a new key ("Make a new key in Accounts ▸ Encryption, or extend this one with the program that made it": Accounts cannot renew it). A certificate revoked as a whole is said so; with none of yours known here for that address, that is said. Tested: pgp::tests::your_security_keys_certificate_travels_and_only_it_is_yours, a_renewal_reaches_every_device_and_is_never_undone, a_renewal_lost_to_an_older_write_comes_back_at_the_next_exchange, a_card_stopped_on_one_computer_never_comes_back_to_it, a_card_read_on_another_computer_signs_only_there, a_shared_certificate_counts_only_with_the_cards_keys_bound, send::tests::the_phone_encrypts_to_its_recipients_and_to_you, share::tests::an_older_sioul_keeps_your_security_keys_waiting, crypto::tests::the_writing_window_says_what_your_key_can_do_here, the_self_copy_line_says_what_can_renew_the_key, a_shared_security_key_is_read_here_never_doubled_by_a_new_key, securitykey::tests::a_shared_key_is_stopped_only_where_it_can_be_read_again.
  • The card's traits (securitykey::Readers, securitykey::Card): one key is opened for one operation (what it says, its PIN in mode 81 or 82, a signature or a deciphering), then closed, which resets it, so that no other program finds it unlocked. CardSigner and CardDecryptor are Sequoia's own Signer and Decryptor: the key is opened when Sequoia asks, never before, and each signature is verified with the certificate's key before it is used. The glue follows sequoia-keystore-openpgp-card: RSA's DigestInfo built by openpgp-card before the card pads and signs it (signature_for_hash), EdDSA as r ‖ s, ECDH's shared secret unwrapped by Sequoia. The tests run it against SoftCard, a software key behind the same traits.
  • Signing at Send: building and sending are two steps (send::build_draft, send::send_built). A message your security key signs is built and signed in the writing window when you press Send (its PIN, maybe a touch), before the ten seconds of "Undo" (Work::SendSigned); "Undo" throws the signed copy away and opens the draft again. A key kept here still signs at the end of the ten seconds. An encrypted answer to a message encrypted to your security key is built the same way, the message opened in the same access, with the same PIN (above, "Encrypted messages you answer"); unsigned, the band only opens it, and offers no "Send unsigned".
  • Opening: nothing touches the key because a message is shown. The Reader says "Encrypted for your security key" and offers Open with your security key (on a phone with neither NFC nor a USB host, the line above); the session key then stays in memory (pgp::SessionKeys, by the SHA-256 of the encrypted bytes), so the message, its attachments and the copy kept with an answer (the same bytes) open again without the key until Sioul closes.
  • The PIN is typed in Sioul's own field (qml/SecurityKeyBand.qml), emptied at once, and held in Rust as Sequoia's Password (encrypted in memory, wiped when dropped): 15 minutes after its last use (securitykey::PinMemory), forgotten when its key's reader goes, on Forget the PIN now, and when Sioul closes; never written anywhere, never logged. On a phone it is never held: typed for each use, dropped when the visit is done (below). A key that asks its PIN for each signature gets it each time. A key with one PIN try left, as it says at the moment of use (the others spent elsewhere), is never given a PIN typed without a word (review of 23 October 2026, F1): CardAccess::run refuses it (CardError::LastTry), nothing sent, and the band says "one try left: type it again only if you are sure" (on a phone, "enter it on a computer, or…"); the PIN typed next in that band is sent (confirm_last_try), once; a PIN held, which the key took before, goes as before. A PIN typed waits a minute for its key at most, then is forgotten and asked again (PIN_WAITS_AT_MOST, F2). The PIN typed reaches Rust as a String, taken into Sequoia's protected memory and the plain copy overwritten (take_pin, F3); QML's text and Qt's QString cannot be overwritten (the field is emptied at once), nor the VERIFY command openpgp-card builds in a plain vector (its issue #73). Sioul asks only the PIN: never the Admin PIN, never to change or unblock one, never to load or make keys.
  • Touch: each key's touch setting (its user interaction flag, DOs D6 to D8: on, fixed, cached) is read without the PIN when the key is read, and kept with it. When the key is asked to sign or open and its setting asks for a touch, the band says so at that moment: "Touch your security key now, and hold your finger on it for a second or two: it waits about 15 seconds." A YubiKey's light says little, and a brief tap is often not taken. A setting that does not read is said "If your key asks for a touch…": a wrong guess costs nothing (Touch::words, smartcard::touch_of). A touch that never came is said as such, with Try again.
  • The certificate is looked up only when you ask (at setup, "Look for it", and "Look for a newer version"): the address written on the key (HTTPS only), the Web Key Directory of your addresses' domains, then keys.openpgp.org by fingerprint; or Import from GnuPG; or a file (Import a file…). securitykey::check keeps it only when it is the key's: each key on the card is in it, not revoked, allowed to do what its slot does, and its public part is the card's (a fingerprint on a card is only data written to it). An expired certificate is said part by part, never as "the key" when one part alone expired: "Its encryption subkey (B81A…BF8C) expired on 8 April 2025: nobody can encrypt to you with it.", the same for a signing or an authentication subkey and for the primary key (securitykey::parts, expired_parts, expiry_words), where it is added or imported and under the key in Accounts; when its subkeys alone expired, Renew for two years is offered right there, with the commands by hand. A month ahead, it is said that it expires soon. An expired encryption subkey still opens what was sent to it: Sequoia's standard policy allows it.
  • GnuPG's steps as buttons (sioul_sync::securitykey::Gnupg), each run on its press only, with your own GnuPG and its home as GnuPG finds it; in a Flatpak, on a phone, or where gpg is not found, the button says so and what to do instead:
    • Import from GnuPG runs gpg --batch --armor --export <fingerprint> and keeps the certificate when securitykey::check says it is the key's, its signatures' numbers read as GnuPG 2.4.4 may write them, one octet too long (compatibility.md, "Read as GnuPG writes them"). GnuPG holding no such key, it says so, with Look for it and Import a file… beside.
    • Renew for two years, shown when the certificate expired or expires within a month and GnuPG holds the key's secret part or knows the security key that does (gpg --list-secret-keys): gpg --quick-set-expire <fingerprint> 2y, then the same with each subkey named (gpg --quick-set-expire <fingerprint> 2y <subkey> <subkey>, as GnuPG lists them, expired ones included: GnuPG's "*" sets only the subkeys not yet expired, and a subkey expired since long stayed so); GnuPG's own pinentry asks for the security key's PIN (and the key for a touch); Sioul never sees the PIN. The certificate is then read again, part by part: renewed, each part is said with its new date; a part still expired is said, and the certificate is not kept as renewed. At the end, done or not, Sioul asks GnuPG to let the key go (gpgconf --kill scdaemon, Gnupg::release): GnuPG's smart card daemon holds a key for itself after any use, and Sioul could not read it next. This is Sioul cleaning up after its own GnuPG run, never a change to GnuPG's settings; gpg starts the daemon again when it needs the key. "Import from GnuPG" exports the public certificate only, which needs no key: nothing to let go. Sioul reads GnuPG's status as it goes (--status-fd): while GnuPG's pinentry is open, the setup says to type the PIN there, then to touch the key; once it closes (on Linux, its process gone), or when no PIN was asked, it says to touch the key now and hold the finger a second or two, until GnuPG answers. A key that waited in vain (GnuPG's timeout, code 62, whatever the language of its words) is said plainly, with Try again. Meanwhile Sioul's own looks at the keys wait (securitykey::may_look): a key opened by Sioul between two of GnuPG's commands, and reset when closed, would undo the PIN GnuPG gave it. The renewed certificate is then exported, checked and kept, and Send it to keys.openpgp.org offered, since others see the new date only once they have the key again. GnuPG not knowing the security key yet, it says to run gpg --card-status once. GnuPG has five minutes before it is said as not answering.
    • Send it to keys.openpgp.org: first one sentence says what goes public (the public key, findable by its fingerprint; each address only once its link is opened; the private key stays on the security key), with Send it and Not now. Sent, over HTTPS only, to keys.openpgp.org's own interface (/vks/v1/upload, then /vks/v1/request-verify for each address not published yet, the mail in your language); Sioul says to which addresses a link went, or that each is published already, or that the certificate it holds names no address (one from keys.openpgp.org itself names none until confirmed: Import from GnuPG first). Never by itself.
  • By hand: under each key's buttons, and under the setup's, the commands that do the same in a terminal (gpg --export --armor <fingerprint> > key.asc; expired or expiring, the two gpg --quick-set-expire), each in a read-only field that selects, with Copy (qml/CommandText.qml). Every command the window shows is offered so: the smart card service's (no service), YubiKey Manager's and GnuPG's for a locked key, gpgconf --kill scdaemon in a Flatpak, and the packages that install an antivirus or the scans' reader. Sioul writes a command in backticks in its words; words from elsewhere (a server's, GnuPG's, a file's name from a message) never become one.
  • Sioul's looks at the keys: none runs by itself but two, each while it waits for something: whether the key whose PIN it holds is still plugged in (the readers' names, every second and a half, no key opened), and, while a band waits for a key to be plugged in, the keys opened once the readers change. Both wait while Sioul's own GnuPG works with a key. Bitwarden's unlock with a security key goes through the browser's WebAuthn, never the OpenPGP card.
  • GnuPG keeps the key for itself after any use, unless pcsc-shared is in scdaemon.conf. Every step that reads the key and meets it held says so, with Let GnuPG release it, rather than waiting: adding the key, reading it again for a certificate when it is not known yet, waiting for it to be plugged in, signing and opening. The lasting way is said with it, the line to copy: pcsc-shared, added to scdaemon.conf in GnuPG's folder (~/.gnupg), lets GnuPG and Sioul share the key. Sioul connects in shared mode, says that another program holds the key, most likely GnuPG, and offers Let GnuPG release it, which runs gpgconf --kill scdaemon on that click, as Sioul also does at the end of its own GnuPG renewal (above).
  • Errors in words (securitykey::CardError): no smart card service (with the command of the system, to copy), no key plugged in (Sioul goes on as soon as it comes), a key held by another program, a wrong PIN with the tries left and a plain warning before the last, a locked key (what unlocks it, elsewhere), a touch missed, the key pulled out, another key than the one known, a signature that does not match.

  • On a phone, held against it (NFC) (23 October 2026): the same steps, in one tap. The band asks for the PIN first, from what the phone knows of the key (pgp/cards.toml, the shared record: securitykey::pin_from), never from a read of its own; then "Hold your security key flat against the back of the phone…" and, once it is held, in that one tap, Sioul reads it, checks it is the key wanted, gives its PIN and signs or opens ("Keep holding it…"), then says "Done: you can take the key away." (seckey-done-lift). Its touch setting asks nothing more: over NFC the tap is the touch, for every setting (on, fixed, cached), and a key that refuses all the same is said so, with the USB-C port as the way (seckey-nfc-touch). Another key held is said so at once (CardError::NotYours) rather than waited for; a key moved away before it finished is said so, and at Send nothing was sent (seckey-moved-away-sign); NFC switched off offers Open NFC settings (Remedy::NfcSettings), and a phone without NFC says so, on a phone that takes no key plugged in (else below). A key locked after three wrong PINs is unblocked on a computer: Sioul never asks the Admin PIN. The PIN is never kept on the phone (keeps_pin): typed in the band's field each time, with the screen kept from captures and from the recent apps' picture while a key is wanted (FLAG_SECURE). At the setup, a key your other device made known, its certificate shared, is kept at once when the phone reads it (shared_certificate): that is how the phone comes to sign (F18 holds: it signs only with a key read there). Sioul reads keys only while one of its bands asks for one and it is in front: other apps (OpenKeychain, Yubico Authenticator) and the key's Yubico OTP tap work as before.

  • On a phone, plugged into its USB-C port (23 October 2026): the same visit, PIN first, never kept; the key plugged in comes before one held. Its touch is asked as on a computer (the band's touch line, whatever the setting: PhoneReaders::by_cable); the waiting line says "hold it, or plug it into the USB-C port" (seckey-hold), "plug it in" on a phone without NFC (seckey-plug-phone), and with NFC off, "plug it in, or switch NFC on", with Open NFC settings (seckey-plug-nfc-off; Readers::reach_note): a phone without NFC, or with NFC off, still takes a key plugged in. Unplugged before it finished: "The key was unplugged before it finished…", at Send "Nothing was sent" (seckey-unplugged-sign); done: "Done: you can unplug the key." Android's permission is asked once for each plugging, while a band asks for the key (the key plugged in before or after): refused, "Android did not let Sioul use the key. Unplug it, plug it in again, and choose Allow." (CardError::UsbRefused), asked again only once it was unplugged; another app holding the key's smart card interface, "Another app holds your security key, Yubico Authenticator or OpenKeychain perhaps…" (CardError::UsbBusy). Yubico OTP stays on: the key's keyboard is never claimed, so a touch while nothing waits types its code as before; while the key waits or works no text field keeps the focus (the band takes it), and a code the key typed into the PIN's field (32 to 64 letters of modhex, then Enter) is never given to the key as its PIN, which would cost a try: the band says so and asks again (typed_by_the_key, seckey-pin-otp; on a computer too). Tested: securitykey::phone::tests::a_key_plugged_into_the_phone_signs_and_opens_its_touch_asked, how_a_phone_reaches_a_key_is_said; the app's a_phone_says_hold_or_plug_and_what_went_wrong, a_code_the_key_typed_is_not_taken_for_its_pin. Tested: securitykey::tests::a_phone_asks_the_pin_first_and_never_keeps_it, a_phone_says_hold_and_what_went_wrong, a_phone_says_to_hold_the_key_where_a_computer_says_to_plug_it, a_key_known_from_another_device_is_kept_when_held_to_the_phone; in sioul-core, another_key_held_to_the_phone_is_said_not_waited_for.

As built

  • The transport (sioul_sync::securitykey::smartcard): openpgp-card 0.7 and card-backend-pcsc 0.5 over the system's PC/SC, pinned; each reader connected by Sioul itself in shared mode (card-backend-pcsc would skip a key GnuPG holds without saying so: a sharing violation is said as such), one transaction per operation, the key disconnected after with ResetCard. The card's status words in words: 63Cx a wrong PIN with x tries left, 6983 locked, 6600 a touch missed, and 6982 too when the PIN was just taken by a key that asks for a touch (to be confirmed on a YubiKey); a card gone mid-way, removed. sioul pgp cards says what each key plugged in says, without its PIN.
  • Logs: openpgp-card traces whole APDUs, a VERIFY's PIN included, and a card's answers. Sioul installs no logger, no crate it builds with installs one, and each program caps the level at "info" as it starts (securitykey::cap_logging), never raising it; a test holds all three.
  • Packaging: Linux builds need pcsc-lite's development files (CI: libpcsclite-dev); the AppImage links pcsc-lite 2.5's client in, built in its job, since Ubuntu 22.04's speaks only protocol 4.4, which the daemons of Debian 13 and Ubuntu 25.10 refuse; the Flatpak has --socket=pcsc.
  • Tested without hardware (7 October 2026): everything above but the transport itself, against the software key; the transport's mapping of status words in a unit test; the window's states pictured on a demo profile (SIOUL_GRAB_STEPS=security-key), whose own software key (PIN 123456) never reaches the smart card service. A test reading a real key without its PIN (a_real_key_reads_without_its_pin) is ignored in every run: its owner runs it with his key plugged in.
  • GnuPG's steps, tested (8 October 2026) in a GnuPG home made for the test, its agent never starting the smart card daemon, never ~/.gnupg, never a card, never the network (gnupg_exports_and_renews_in_a_throwaway_home): the software key's certificate imported there, exported and checked against the key; a key GnuPG does not hold, and a fingerprint that is none, said so; a key of its own made without a passphrase, valid a year, renewed for two, its subkey too. keys.openpgp.org against a stand-in server (keys_openpgp_gets_the_certificate_and_mails_each_address): the certificate as it is, then a link asked for the address not published yet, in the language given; nothing asked when all are; a refusal said with the server's words; never over plain HTTP. The commands' fields: tools/qml-test.sh tst_takes.qml (words, then each command apart; a lone backtick, or a line not Sioul's own, stays words). Not tried yet with a real security key and GnuPG's pinentry.
  • Tried with a real key (8 October 2026): a YubiKey 5 (OpenPGP application 3.4, Ed25519 and Cv25519, a touch asked for each use), on the native build: the setup, an expired key and its expired subkeys renewed through GnuPG, mail signed and encrypted at Send, and encrypted mail opened. GnuPG's '*' leaves expired subkeys as they are, so Renew names each one.
  • Limits (review of 23 October 2026, F4): Sioul gives the PIN only to a key that answers with the identity and the fingerprints of the one it knows, but a counterfeit card made to copy them (they are public), held within centimetres of the phone or plugged in, would receive the PIN; it gets no private key, and the PIN is useless without the real key. A reader under someone else's control on a computer is the same risk. Nothing on the wire tells a real key from such a copy (the OpenPGP card has no attestation Sioul could check).
  • The phone's transport (sioul_sync::securitykey::phone, sioul_sync::securitykey::ocard; crates/sioul-app/src/nfc.rs, android/main.cpp, SecurityKeyLink.java): Android has no smart card service, so openpgp-card gets Sioul's own backend, whose APDUs go through Android's IsoDep; the card is read, given its PIN and used by the same code as a computer's (ocard::OpenCard). Reader mode (NfcAdapter.enableReaderMode: NFC-A and B, no NDEF read, a presence check every 50 ms) is on only while a band asks for the key, ahead of other apps, and off after; no tag filter in the manifest, so holding the key never opens Sioul, and NFC is not required to install it. Each exchange is at most what the phone's controller carries (getMaxTransceiveLength); extended length only where both the key and the phone take it, else short APDUs, chained, their long answers read with GET RESPONSE (phone::limit); five seconds an exchange; one visit makes at most 512 exchanges (phone::MOST_EXCHANGES, F5), so that a card that answers "more bytes wait" (61xx) without end is let go, said as no OpenPGP card. At the end of each use the PIN's checks are undone (VERIFY with P1 = FF, OpenPGP card specification 3.4.1, §7.2.2), then the key is let go: Android hands it to no other app until it has left the field (NfcAdapter.ignore). Nothing of an APDU is logged, in Rust, the bridge or Java; each command's and answer's copies are wiped. Tested without hardware: an OpenPGP card emulated APDU by APDU over the software key (securitykey::phone::tests: Ed25519 and RSA signing, RSA and Curve25519 opening; the owner's kind of key, Ed25519 and Cv25519, end to end: a message Sioul encrypts opened through PSO:DECIPHER's ECDH cipher DO (A6 { 7F49 { 86 point } }, the ephemeral point's 32 bytes without OpenPGP's 0x40, the shared secret back), a signature made through PSO:CDS verified by Sequoia (the_owners_kind_of_key_opens_and_signs_end_to_end; X25519 lent to the stand-in by x25519-dalek, in tests only), short and extended APDUs, chaining, GET RESPONSE, every touch setting and a refusal, a key moved away mid-way, NFC off, no NFC, a bank card, the phone's frame, nothing logged), and the Java and the bridge read from their sources (android/jvm-checks/SecurityKeyCheck.java). Plugged in (SecurityKeyLink.java, Ccid.java): while a band asks for the key, the USB devices plugged in are looked at, and those heard plugged in (a receiver registered in code, not exported from Android 13, and before it reached only by senders holding Sioul's signature-level permission, the system and Sioul itself (F6); a key unplugged believed only once Android no longer lists it, Android's answer heard only for a key Sioul asked for; Sioul in the background, the key is let go and the receiver unregistered, unless Android's own dialog came in front, and back in front the key is looked for again (F8); no USB filter in the manifest, so plugging the key never opens Sioul, and USB host is not required to install it); of a key, its smart card interface (class 0x0B) alone is claimed, never its keyboard (Yubico OTP) nor FIDO. Android's permission is asked by UsbManager.requestPermission, its answer through a PendingIntent for Sioul's package alone, mutable from Android 12. The APDUs go as CCID messages over the two bulk endpoints (PC_to_RDR_IccPowerOn, XfrBlock with sequence numbers, IccPowerOff): packets of the endpoint's size, a zero-length packet after a message that fills its last one, answers read across transfers, a key waiting for a touch, by time extensions or silent, read through for 25 seconds (a YubiKey waits 15), a second per transfer, a key unplugged meanwhile said at once (F9); short APDUs, chained by openpgp-card. Unplugged mid-way, a key gone (CardError::Removed); when the band no longer asks, the key's PIN checks are undone, it is powered off, its interface released and the connection closed: other apps have it again. Each message's buffer is wiped once sent or read. Tested on the JVM: android/jvm-checks/CcidCheck.java (the messages' bytes, packets, a key waiting for a touch, an answer to another message, a reader's failure, a key unplugged, buffers wiped) and the USB parts of SecurityKeyCheck.java. Not yet tried on a phone with a real key, by NFC or USB-C.
  • Next: the Flatpak; a CI job with a virtual card (opcard-rs through vsmartcard); then a real key on a phone, with its owner.

Antivirus

  • A program is never opened from mail: an attachment that runs (.exe, .js, .lnk, .desktop, .terminal, .iso…) can only be saved, and opening it stays your own act, outside Sioul. Files opened or saved from mail carry the system's mark that they came from the Internet (Windows' Mark of the Web, macOS' quarantine), so Office opens them in Protected View and macOS checks them.
  • An attachment is checked before it opens or is saved, when an antivirus is there (porch.md). Without one, nothing is refused: Sioul says the file will not be checked, asks before opening or saving it ("Open it unchecked"), and gives the command that installs one on this system, to copy.
  • Each system's own antivirus, nothing shipped:
    • Windows: the Antimalware Scan Interface (AMSI), which hands the file to the antivirus Windows already runs (Defender by default).
    • Linux and macOS: ClamAV when the system has it (its packages; Homebrew on a Mac), as macOS always had it. Nothing of ClamAV ships with Sioul: ClamAV is GPL-2.0-only, and a separate program beside Sioul would be allowed, but the packages stay simpler and every system keeps its antivirus current itself. A Flatpak does not reach the system's ClamAV: there, Sioul asks.
  • On a phone, no antivirus, and none claimed. Android gives an app no way to have another app scan a file (nothing like AMSI), the antivirus apps made for it offer none to call, and ClamAV does not run there. So a phone never scans and never asks: the Reader says that attachments are not checked there, and so does the line after each opening or save. What protects instead:
    • Sioul's own refusals, as on a computer: mail set aside keeps its attachments closed, hostile mail shows nothing until you choose to read it, and programs, scripts and installers never open; on a phone Android's installers join them (.apk, .apks, .apkm, .xapk; links::is_phone_program). Each can still be saved, your own act.
    • The type from the name, never from the message: the app is asked to open what the name says (compose::mime_of, else Android's own table for the extension), and never as Android's installer, whatever either says.
    • Android's sandbox: the attachment goes to the app you choose by a content:// address lent to read only, for that one file, never to write, never kept for later; that app reaches nothing else of Sioul's. Sioul cannot install anything: it does not hold REQUEST_INSTALL_PACKAGES.
    • Why no question each time: on a computer, the question points at an antivirus you can install. A phone has none Sioul could call, so the question would come at every opening with the same answer, and a question that always comes is soon answered without being read. The words say instead, in the Reader and after each opening, that the file was not checked.
  • Opening on a phone: in the app you choose, Android's own "Open with" when no app is set for the type, else the one you set: a PDF in your PDF reader, a picture in the gallery. Sioul's own PDF view exists on computers only (Qt PDF has no Android version), and a computer opens attachments with the desktop's application too, so the two behave alike. When no app opens the type, the line says so, and Save… keeps the file with yours.
  • Saving on a phone: Save… asks Android where (its own document picker: Downloads, a folder, a cloud's app), since a phone's Downloads folder is not Sioul's to write; the file is copied there, and leaves Sioul's cache, saved or not.
  • Too big is not clean. ClamAV stops at its limits (a file's size, what an archive holds, how deep it nests; MaxFileSize, MaxScanSize, MaxFiles, MaxRecursion) and, unless told otherwise, calls the file clean without reading the rest (checked with ClamAV 1.4.6: a file over --max-filesize comes out OK). Its scanner is always run with --alert-exceeds-max=yes, which names such a file Heuristics.Limits.Exceeded…; Sioul reads that as "not scanned: too big", in calm words, and asks before opening or saving, as without an antivirus. Its daemon has no such option for one scan: its own configuration must say AlertExceedsMax yes (the file clamdscan reads: /etc/clamd.d/scan.conf on Fedora, /etc/clamav/clamd.conf on Debian and Arch, Homebrew's under etc/clamav/). Without that line, Sioul does not ask the daemon and runs the scanner, slower (seconds, as it loads the signatures) but true. AMSI takes at most 4 GB: a larger file is too big there too.
  • One interface in Sioul (scan(file) → clean | threat | too big | unavailable), one implementation per system; install_hint() gives the command (sudo dnf install clamav clamav-update && sudo freshclam on Fedora, apt, pacman, zypper elsewhere, brew install clamav on a Mac).

As built

  • sioul-sync/src/antivirus.rs: ClamAV's daemon when its configuration alerts on its limits (daemon_alerts), then its scanner with the system's signatures, then with Sioul's own in ~/.local/share/sioul/clamav, which Sioul refreshes with the system's freshclam once a day when the system keeps none. On Windows, AMSI. When none answers: the question, with the command.
  • Tested on Linux without ClamAV (the question comes, with Fedora's command). The AMSI code is written to Microsoft's documented sequence and is checked by the Windows build of the workflow below; it has not run on Windows yet.
  • On Android (crates/sioul-app/src/attachments.rs): checked_attachment takes the phone's way there, the computer's unchanged: no scan (attachments::check), the installers kept closed (attachments::kept_closed), the words. antivirus::scan answers "unavailable" on Android without trying, and install_hint says to open the file on a computer that has one. Java (android/package/src/com/aurelienpierre/sioul/Attachments.java) hands the file through Qt's FileProvider (<package>.qtprovider), whose folders are android/package/res/xml/qtprovider_paths.xml, which replaces Qt's: Qt's four, and the attachments' folder of the cache, sioul/attachments/, alone. Until 8 October 2026 nothing opened on a phone: since Android 7 a file:// address handed to another app throws FileUriExposedException, and Qt 6.11's own openUrl, which turns a file into an address of that provider, found no folder of it holding Sioul's cache and gave up without a word. One FileProvider only: when there are two, Qt hands every file it opens (papers, notes, letters) to the one that is not its own. The address is built as FileProvider builds it (Uri.encode part by part) and read back by it. Save… is AttachmentSave.java, an activity never shown: ACTION_CREATE_DOCUMENT, the copy on a thread of its own, the cache's copy taken out, then Rust told (AttachmentSave.nativeSaved, sioul_attachment_saved), which says it in the status line.
  • Tested on a computer (8 October 2026): the address, the type, what an app is handed (ACTION_VIEW, read only, no other grant) and what Android is asked to save, the provider's folders and the manifest (android/jvm-checks/AttachmentsCheck.java); the phone's decision and its words (attachments::tests); the Reader's words and buttons in English and French, at a computer's size and at a phone's (tools/demo/run.sh OUT en attachments phone, the step list "attachments", nothing opened).

Windows, macOS, then Android

  • Folders: the system's own (directories): %APPDATA%\\Sioul on Windows, ~/Library/Application Support/Sioul on macOS, XDG on Linux.
  • Maildir: : is forbidden in Windows file names, so the info separator is ! there, as mbsync does.
  • Passwords: the keyring crate's Windows Credential Manager and macOS Keychain backends.
  • Notifications: Windows toasts and macOS notifications (notify-rust).
  • Icons: the Breeze icons Sioul uses (LGPL-3.0) ship inside it, since only Linux desktops have them.
  • DNS for the sender checks: the system's resolver on each (hickory reads Windows' own settings).
  • Building: Qt 6 from Qt's installer on Windows (MSVC) and macOS; a CI build on the three systems for each change.
  • Packages: Flatpak on Linux, an installer on Windows (MSIX or Inno Setup, windeployqt), a disk image on macOS.
  • Android: the same window, with Qt for Android (architecture.md, android.md).

As built

  • Folders: the XDG variables when set, on every system (tests set them); else XDG on Linux, %APPDATA% and %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS (directories). Downloads and the cache follow the same rule.
  • Maildir: ! before the flags on Windows; : and ! are both read everywhere, so a Maildir copied across reads the same.
  • Keyring: the Secret Service on Linux, the Credential Manager on Windows, the Keychain on macOS (the keyring crate's backend per system).
  • Notifications: the "copy" button only where the desktop offers buttons (Linux); elsewhere the code is in the text.
  • Icons: the Breeze icons Sioul uses ship inside the window, light and dark (tools/bundle-icons.py, 1.4 MB, Breeze's LGPL-3.0 licence alongside), one folder per size; the desktop's own theme still comes first on Linux. The light or the dark set follows the window's colours, as they change (qt-quick.md). They are SVG: each package carries Qt's SVG icon engine and image plugin (iconengines, imageformats), which its build checks, and the CI reads every name the window uses from the bundle alone on the three systems (crates/sioul-app/tests/icons.rs). The AppImage of 0.0.5 and before lacks the icon engine, without which Qt reads no SVG icon of a theme, Breeze's on the desktop as much as Sioul's own.
  • Symbols: the few Sioul writes (→ ▸ ▾ ✓ ⚙) ship as Sioul Symbols, cut from DejaVu Sans (tools/make-symbols-font.py, 15 KB, DejaVu's licence alongside), first in line after the text's own font, so that Qt does not look for them through all the system's fonts.
  • Style: Qt Quick's Basic style everywhere, the tips Qt makes itself included (QT_QUICK_CONTROLS_STYLE, unless set): the system's style (KDE's Breeze, Android's Material) is not loaded for them.
  • OpenPGP: Sequoia with its pure-Rust cryptography, the same on the three systems.
  • Building: .github/workflows/build.yml tests the core, sync and command line, and builds the window, on Linux, Windows and macOS; on Linux it also runs rustdoc on the five crates, warnings as errors. It runs on each push to main that touches code (the docs and the website are skipped), and by hand (Actions, "Build on three systems"), where a Windows folder and a macOS .dmg can be asked for.
  • Packages: .github/workflows/release.yml builds four from a version tag and publishes them as a GitHub release (0.0.1, a pre-release, on 4 October 2026): a Windows installer (Inno Setup, packaging/windows/sioul.iss, after windeployqt), one macOS .dmg for Apple silicon and Intel (the two builds joined by lipo, signed ad hoc, not notarised), an AppImage, and a Flatpak bundle (packaging/flatpak/). None is signed yet: Windows and macOS say so the first time.
  • Android: an experiment, built: the window as a library that Qt for Android packs into an APK, by .github/workflows/android.yml at each change that touches the code; installed by hand, in no store, not ready (android.md).

Order

  1. Mail, writing to the server: folders, flags, archive, delete, junk, move; writing in Markdown, replies and forwards; sending; drafts.
  2. Contacts and calendars: discovery, sync both ways, the list and the agenda, editing, invitations from mail.
  3. PGP: reading (decrypt, verify) and writing (sign, encrypt), keys from Autocrypt and WKD.
  4. Antivirus and Windows: AMSI, the system's ClamAV elsewhere, the system folders, the Maildir separator, the icons, the CI on three systems.