diff --git a/specs/013-mail-rules/spec.md b/specs/013-mail-rules/spec.md new file mode 100644 index 0000000..942ae00 --- /dev/null +++ b/specs/013-mail-rules/spec.md @@ -0,0 +1,48 @@ +# Visual server-side mail rules + +**Status**: Prototype proposed upstream +**Proposal**: https://ideas.tb.pro/p/visual-server-side-mail-rules-using-jmap-sieve +**Issue**: https://github.com/thunderbird/stormbox/issues/128 + +## Goal + +Stormbox shall let a signed-in user build ordered mail rules visually or edit their complete Sieve source and run them on the mail server through JMAP for Sieve (RFC 9661). The feature remains fully browser-owned and does not add a Stormbox application backend. + +## Requirements + +| ID | Requirement | +|---|---| +| MR-1 | When the account advertises `urn:ietf:params:jmap:sieve`, the account menu shall offer a Mail Rules editor. When the capability is absent, the editor shall explain that server-side rules are unavailable. | +| MR-2 | The editor shall support ordered, enabled/disabled rules; recursively nested all/any/not condition groups; From, To, To/Cc, Subject, and custom-header conditions; and exact, contains, or wildcard matching. | +| MR-3 | The editor shall support move, mark read, star, forward a copy, discard, and stop-processing actions, exposing only actions supported by the account's advertised Sieve extensions. | +| MR-4 | The server-stored Sieve source shall be authoritative. Stormbox shall parse scripts into a source-ranged syntax tree and project every completely representable script into the visual rule model. UI-only identifiers may be regenerated and shall not duplicate the executable rule model in metadata. The complete source shall remain available in a Source editor. | +| MR-5 | Every save shall use the durable mutation outbox, upload the generated or directly edited script, call `SieveScript/validate`, and activate it only after validation succeeds. | +| MR-6 | A save shall use the last observed `SieveScript` state and reject a concurrent server change instead of overwriting it. | +| MR-7 | The active script shall be edited in place. If it is completely representable, the user may switch between Visual and Source editors. If it is not representable, Stormbox shall open its complete source, explain the unsupported construct, and only allow switching to Visual after the edited source becomes representable. | +| MR-8 | Move actions shall use the RFC 9042 `:mailboxid` extension when supported, retaining a readable hierarchy path as the fallback mailbox name. | +| MR-9 | Invalid rule data, unsupported actions, server validation failures, and server conflicts shall remain visible and recoverable in the editor. Controls shall be disabled while a save is in flight. | +| MR-10 | The editor shall be keyboard accessible, trap focus while open, and confirm before discarding unsaved changes. | +| MR-11 | Stormbox shall only regenerate a script from the visual model when every required extension and executable construct is within the visual editor's semantics-preserving subset. Direct Source edits may use the server's full Sieve surface and remain subject to server validation. | + +## Initial scope + +- Server-side filtering of newly delivered mail. +- One active script edited visually or as source at a time; JMAP may retain other scripts and permits at most one active script. +- A source parser, deliberately limited visual projection, and Sieve emitter. +- Compatible existing scripts can be visualized and edited in place. +- Unsupported syntax falls back to the complete editable source with an exact visual-projection error. +- Visual-to-source switching is always available; source-to-visual switching requires complete projection. + +## Non-goals + +- Visually representing every Sieve extension or control-flow construct. +- Rich source-editor features such as syntax highlighting, completion, and inline diagnostics. +- Retroactively applying rules to existing messages. +- Shared-account rule management. +- A Cloudflare Worker rule engine; the deployment bridge only adapts browser CORS and WebSocket authentication. + +## Verification + +- Unit tests cover syntax parsing, visual projection, nested grouping, escaping, capability checks, source round-tripping, conflict handling, server validation, and outbox integration. +- Component tests cover loading, nested editing, Visual/Source switching, raw-source fallback, save locking, and discard confirmation. +- Local-stack Playwright coverage asserts the visible editor result, durable mutation completion, and the active script directly through JMAP in Chromium and Firefox. diff --git a/src/App.vue b/src/App.vue index ad679cd..e8587dd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -55,6 +55,7 @@ import AppDrawer from './components/AppDrawer.vue'; import TopNavMenu from './components/TopNavMenu.vue'; import AccountAvatarMenu from './components/AccountAvatarMenu.vue'; import WelcomeModal from './components/WelcomeModal.vue'; +import MailRulesDialog from './components/MailRulesDialog.vue'; import SpotlightOverlay from './components/SpotlightOverlay.vue'; import FeatureBeaconLayer from './components/FeatureBeaconLayer.vue'; import FeatureBeaconMenu from './components/FeatureBeaconMenu.vue'; @@ -155,6 +156,7 @@ const folderListWidth = ref(DEFAULT_COLUMN_WIDTHS.folderList); const folderListHidden = ref(false); const showWelcomeModal = ref(false); const showSettingsDialog = ref(false); +const showMailRules = ref(false); const spotlight = useFeatureSpotlight(() => createSpotlightScripts({ composeStore, currentSpace: () => space.value, @@ -184,11 +186,12 @@ const shortcutsEnabled = computed(() => authStore.status === AUTH_STATE.CONNECTED && !showWelcomeModal.value && !showSettingsDialog.value + && !showMailRules.value && beaconStore.openId == null, ); // Beacon dots would sit on top of these dialogs' scrims. const showFeatureBeacons = computed(() => - !showWelcomeModal.value && !showSettingsDialog.value); + !showWelcomeModal.value && !showSettingsDialog.value && !showMailRules.value); const windowWidth = ref(typeof window === 'undefined' ? COMPACT_READING_WIDTH : window.innerWidth); const wantsMessageDetailView = computed(() => mailStore.selectedMessageId != null); // Multi-select never opens the message view: the bulk actions live in @@ -729,7 +732,9 @@ function unwatchSystemTheme() { @open-settings="showSettingsDialog = true" /> - + @@ -819,6 +824,7 @@ function unwatchSystemTheme() { singular-item-label="message" :total="mailStore.bulkOperation.total" /> + import { computed, ref } from 'vue'; import { onClickOutside } from '@vueuse/core'; -import { LogOut, Settings } from '@lucide/vue'; +import { + ListFilter, LogOut, Settings, +} from '@lucide/vue'; import { useAuthStore } from '../stores/auth-store'; import { ACCOUNTS_URL } from '../defines'; import { senderAvatarStyle, senderInitials } from '../utils/sender-avatar'; const authStore = useAuthStore(); +const emit = defineEmits<{ + (event: 'show-mail-rules'): void; +}>(); const detailsEl = ref(null); @@ -26,6 +31,10 @@ function onLogout() { if (detailsEl.value) detailsEl.value.open = false; authStore.logout(); } +function onShowMailRules() { + if (detailsEl.value) detailsEl.value.open = false; + emit('show-mail-rules'); +}