Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions specs/013-mail-rules/spec.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -729,7 +732,9 @@ function unwatchSystemTheme() {
@open-settings="showSettingsDialog = true"
/>
<FeatureBeaconMenu @reveal="revealBeacon" />
<AccountAvatarMenu />
<AccountAvatarMenu
@show-mail-rules="showMailRules = true"
/>
</div>
</header>

Expand Down Expand Up @@ -819,6 +824,7 @@ function unwatchSystemTheme() {
singular-item-label="message"
:total="mailStore.bulkOperation.total"
/>
<MailRulesDialog v-if="showMailRules" @close="showMailRules = false" />
<WelcomeModal
v-if="showWelcomeModal"
:active-spotlight="activeSpotlight"
Expand Down
15 changes: 14 additions & 1 deletion src/components/AccountAvatarMenu.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
<script setup lang="ts">
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<HTMLDetailsElement | null>(null);

Expand All @@ -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');
}
</script>

<template>
Expand All @@ -42,6 +51,10 @@ function onLogout() {
</span>
<span class="account-menu__email">{{ identityLabel }}</span>
</div>
<button class="account-menu__item" type="button" role="menuitem" @click="onShowMailRules">
<ListFilter :size="16" :stroke-width="1.75" aria-hidden="true" />
<span>Mail Rules</span>
</button>
<a class="account-menu__item" :href="ACCOUNTS_URL" role="menuitem">
<Settings :size="16" :stroke-width="1.75" aria-hidden="true" />
<span>Account Settings</span>
Expand Down
131 changes: 131 additions & 0 deletions src/components/MailRuleSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, nextTick } from 'vue';
import { Check } from '@lucide/vue';

import AppDropdown from './AppDropdown.vue';
import { closeContainingDropdown } from '../utils/dropdown';

interface MailRuleSelectOption {
value: string;
label: string;
disabled?: boolean;
}

const props = withDefaults(defineProps<{
modelValue: string;
options: readonly MailRuleSelectOption[];
controlLabel: string;
placeholder?: string;
disabled?: boolean;
}>(), {
placeholder: 'Select an option',
disabled: false,
});

const emit = defineEmits<{
(event: 'update:modelValue', value: string): void;
}>();

const selectedLabel = computed(() =>
props.options.find((option) => option.value === props.modelValue)?.label ?? props.placeholder);

function containingDropdown(source: EventTarget | null): HTMLDetailsElement | null {
if (!(source instanceof Element)) return null;
const details = source.closest('details');
return details instanceof HTMLDetailsElement ? details : null;
}

function enabledOptions(details: HTMLDetailsElement): HTMLButtonElement[] {
return Array.from(details.querySelectorAll<HTMLButtonElement>('[data-rule-option]:not(:disabled)'));
}

async function openFromKeyboard(event: KeyboardEvent): Promise<void> {
if (props.disabled) return;
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
const details = containingDropdown(event.currentTarget);
if (!details) return;
details.open = true;
await nextTick();
const options = enabledOptions(details);
const target = event.key === 'ArrowDown' ? options[0] : options.at(-1);
target?.focus();
}

function moveOptionFocus(event: KeyboardEvent): void {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const details = containingDropdown(event.currentTarget);
if (!details) return;
const options = enabledOptions(details);
const current = event.currentTarget;
if (!(current instanceof HTMLButtonElement) || options.length === 0) return;
event.preventDefault();

const currentIndex = options.indexOf(current);
let targetIndex: number;
if (event.key === 'Home') targetIndex = 0;
else if (event.key === 'End') targetIndex = options.length - 1;
else if (event.key === 'ArrowDown') targetIndex = (currentIndex + 1) % options.length;
else targetIndex = (currentIndex - 1 + options.length) % options.length;
options[targetIndex]?.focus();
}

function selectOption(option: MailRuleSelectOption, event: Event): void {
if (props.disabled || option.disabled) return;
const details = containingDropdown(event.currentTarget);
emit('update:modelValue', option.value);
closeContainingDropdown(event);
details?.querySelector<HTMLElement>('summary')?.focus();
}
</script>

<template>
<AppDropdown class="mail-rule-select" :disabled="disabled">
<summary
class="app-dropdown__summary app-dropdown__summary--control mail-rule-select__summary"
:aria-label="controlLabel"
:aria-disabled="disabled ? 'true' : undefined"
aria-haspopup="menu"
:tabindex="disabled ? -1 : undefined"
@keydown="openFromKeyboard"
>{{ selectedLabel }}</summary>
<div class="app-dropdown__menu mail-rule-select__menu" role="menu" :aria-label="controlLabel">
<button
v-for="option in options"
:key="option.value"
class="app-dropdown__item"
type="button"
role="menuitemradio"
:disabled="disabled || option.disabled"
:aria-checked="option.value === modelValue"
:data-rule-option="option.value"
@click="selectOption(option, $event)"
@keydown="moveOptionFocus"
>
<Check v-if="option.value === modelValue" :size="14" aria-hidden="true" />
<span v-else aria-hidden="true" />
<span>{{ option.label }}</span>
</button>
</div>
</AppDropdown>
</template>

<style scoped>
.mail-rule-select {
width: 100%;
min-width: 0;
}
.mail-rule-select__summary {
box-sizing: border-box;
width: 100%;
min-width: 0;
white-space: nowrap;
}
.mail-rule-select__menu {
min-width: max(100%, 190px);
}
.mail-rule-select :deep(.app-dropdown__item:disabled) {
cursor: default;
opacity: 0.45;
}
</style>
Loading