mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
feat: warn about the server's forward limit and an auto-reply too long for it (bulwarkmail/webmail#1155, bulwarkmail/webmail#1208)
This commit is contained in:
@@ -42,6 +42,8 @@ import type {
|
||||
FilterActionType,
|
||||
} from '../../lib/sieve/types';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { useFilterStore } from '../../stores/filter-store';
|
||||
import { forwardsOfEditedRule, ruleForwardLimit } from '../../lib/sieve/forward-limit';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all'];
|
||||
@@ -72,12 +74,19 @@ interface FilterRuleModalProps {
|
||||
mailboxes: Mailbox[];
|
||||
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
||||
periodsSupported?: boolean;
|
||||
/**
|
||||
* The server's cap on forwards per message, and the forwards a message can
|
||||
* have collected when it reaches this rule / still collect below it (see
|
||||
* lib/sieve/forward-limit.ts). Left out: read from the filter list the
|
||||
* Filters settings show.
|
||||
*/
|
||||
forwardContext?: { maxRedirects: number | null | undefined; before: number; after: number };
|
||||
onSave: (rule: FilterRule) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilterRuleModal({
|
||||
visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose,
|
||||
visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, forwardContext, onSave, onClose,
|
||||
}: FilterRuleModalProps) {
|
||||
const c = useColors();
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
@@ -120,6 +129,25 @@ export function FilterRuleModal({
|
||||
|
||||
const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]);
|
||||
|
||||
// A message can collect only so many forwards on the server (#1155, #1208).
|
||||
const listedRules = useFilterStore((s) => s.rules);
|
||||
const listedForward = useFilterStore((s) => s.vacationForward);
|
||||
const listedMaxRedirects = useFilterStore((s) => s.sieveCapabilities?.maxNumberRedirects);
|
||||
const listedAround = useMemo(
|
||||
() => forwardsOfEditedRule(listedRules, listedForward, editedRule?.id),
|
||||
[listedRules, listedForward, editedRule?.id],
|
||||
);
|
||||
const forwardLimit = ruleForwardLimit(
|
||||
{ actions, stopProcessing },
|
||||
forwardContext ? forwardContext.maxRedirects : listedMaxRedirects,
|
||||
forwardContext ?? listedAround,
|
||||
);
|
||||
const ruleForwardCount = actions.filter((a) => a.type === 'forward').length;
|
||||
const actionOptionsFor = (action: FilterAction) =>
|
||||
action.type === 'forward' || forwardLimit.canAddForward
|
||||
? actionTypeOptions
|
||||
: actionTypeOptions.filter((o) => o.value !== 'forward');
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
() => ALL_FIELDS.map((f) => ({ value: f, label: t(`settings.filters.condition_fields.${f}`, f) })),
|
||||
[t],
|
||||
@@ -407,7 +435,7 @@ export function FilterRuleModal({
|
||||
<Select
|
||||
value={action.type}
|
||||
onChange={(v) => updateAction(index, { type: v as FilterActionType })}
|
||||
options={actionTypeOptions}
|
||||
options={actionOptionsFor(action)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Pressable
|
||||
@@ -489,6 +517,16 @@ export function FilterRuleModal({
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{forwardLimit.unavailable && ruleForwardCount > 0 && (
|
||||
<Text style={styles.forwardWarning}>
|
||||
{t('settings.filters.forward_unavailable', 'This server does not allow forwarding. Forward actions in filter rules have no effect.')}
|
||||
</Text>
|
||||
)}
|
||||
{forwardLimit.overLimit && (
|
||||
<Text style={styles.forwardWarning}>
|
||||
{t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: forwardLimit.limit ?? 0 })}
|
||||
</Text>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => setActions((prev) => [...prev, makeEmptyAction()])}
|
||||
style={styles.addRow}
|
||||
@@ -645,6 +683,7 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
optionLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
hint: { ...typography.caption, color: c.mutedForeground },
|
||||
forwardWarning: { ...typography.caption, color: c.warning, marginTop: spacing.sm },
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -5,7 +5,8 @@ import { MoveSheet } from '../MoveSheet';
|
||||
import { FilterRuleModal } from './FilterRuleModal';
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
import { getSieveAccountId, getSieveCapabilities } from '../../api/sieve';
|
||||
import { FiltersLoginChangedError, OpaqueFiltersError, updateAccountFilters } from '../../api/sieve-rules';
|
||||
import { FiltersLoginChangedError, OpaqueFiltersError, readAccountFilters, updateAccountFilters } from '../../api/sieve-rules';
|
||||
import { forwardsAround, inRunOrder } from '../../lib/sieve/forward-limit';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { servedLoginOf } from '../../lib/draft-login';
|
||||
import { supportsPeriods } from '../../lib/sieve/period';
|
||||
@@ -87,6 +88,26 @@ export function QuickRuleSheet({ visible, email, mailboxes, onClose }: QuickRule
|
||||
const junk = React.useMemo(() => findJunkMailbox(mailboxes), [mailboxes]);
|
||||
const periodsSupported = supportsPeriods(getSieveCapabilities()?.sieveExtensions);
|
||||
|
||||
// Where the new rule runs among the account's rules, for the server's
|
||||
// redirect limit: at the top, behind the out of office forwarding
|
||||
// (webmail quick-rule-host.tsx; lib/sieve/forward-limit.ts).
|
||||
const editorOpen = visible && step.kind === 'editor';
|
||||
const [forwards, setForwards] = React.useState({ before: 0, after: 0 });
|
||||
React.useEffect(() => {
|
||||
setForwards({ before: 0, after: 0 });
|
||||
if (!editorOpen) return;
|
||||
let current = true;
|
||||
const accountId = openedIn.current.accountId ?? undefined;
|
||||
readAccountFilters(accountId)
|
||||
.then((parsed) => {
|
||||
if (!current || parsed.isOpaque) return;
|
||||
const runOrder = inRunOrder(parsed.rules, parsed.vacationForward);
|
||||
setForwards(forwardsAround(runOrder, runOrder.length - parsed.rules.length, false));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => { current = false; };
|
||||
}, [editorOpen]);
|
||||
|
||||
const [sender] = subject.senders;
|
||||
const senderLabel = subject.senders.length === 1
|
||||
? (sender.name || sender.email)
|
||||
@@ -248,6 +269,10 @@ export function QuickRuleSheet({ visible, email, mailboxes, onClose }: QuickRule
|
||||
initialRule={step.kind === 'editor' ? step.rule : undefined}
|
||||
mailboxes={mailboxes}
|
||||
periodsSupported={periodsSupported}
|
||||
forwardContext={{
|
||||
maxRedirects: getSieveCapabilities(openedIn.current.accountId ?? undefined)?.maxNumberRedirects,
|
||||
...forwards,
|
||||
}}
|
||||
onSave={(rule) => void saveEditorRule(rule)}
|
||||
onClose={finish}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
parseLocalInput,
|
||||
utcIsoToLocalInput,
|
||||
} from '../../lib/vacation-dates';
|
||||
import { STALWART_VACATION_LIMITS, vacationOversize } from '../../lib/vacation-limits';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { getSieveCapabilities } from '../../api/sieve';
|
||||
|
||||
// Ported from the webmail's components/settings/vacation-settings.tsx. Dates
|
||||
// are typed as local wall-clock time and sent as RFC 8621 UTCDate strings;
|
||||
@@ -122,6 +125,11 @@ export function VacationSettings() {
|
||||
// Only what was changed here is sent: the rest stays as the server has it,
|
||||
// which another device may have changed since this one loaded.
|
||||
const forwardChanged = forwardSettings !== undefined && !sameForward(forwardSettings, store.forward);
|
||||
// Kept here, the message goes on through the filter rules, whose forwards
|
||||
// share the server's limit with this one (#1208).
|
||||
const forwardLimit = getSieveCapabilities(managedAccountId ?? undefined)?.maxNumberRedirects;
|
||||
const forwardOverLimit = store.forwardAvailable && forwardEnabled && forwardKeep
|
||||
&& typeof forwardLimit === 'number' && forwardLimit > 0 && 1 + store.otherForwards > forwardLimit;
|
||||
|
||||
const fromParsed = parseLocalInput(fromDate);
|
||||
const toParsed = parseLocalInput(toDate);
|
||||
@@ -137,6 +145,22 @@ export function VacationSettings() {
|
||||
return fromParsed < todayStart;
|
||||
}, [fromParsed]);
|
||||
|
||||
// What a save sends, measured against the server's limits as you type.
|
||||
const sizeLimits = jmapClient.hasAccountCapability('urn:stalwart:jmap', managedAccountId ?? undefined)
|
||||
? STALWART_VACATION_LIMITS
|
||||
: null;
|
||||
const oversize = useMemo(() => {
|
||||
const sanitizedHtml = hasHtmlContent ? stripDangerousTags(htmlBody) : null;
|
||||
const textBody = body.trim() || !sanitizedHtml ? body : htmlToPlainText(sanitizedHtml);
|
||||
return vacationOversize({ subject: subject.trim(), textBody, htmlBody: sanitizedHtml }, sizeLimits);
|
||||
}, [hasHtmlContent, htmlBody, body, subject, sizeLimits]);
|
||||
const subjectTooLong = oversize.subject && sizeLimits
|
||||
? t('settings.vacation.warnings.subject_too_long', 'The subject is too long for the server (at most {max} bytes).', { max: sizeLimits.subject })
|
||||
: null;
|
||||
const bodyTooLong = oversize.body && sizeLimits
|
||||
? t('settings.vacation.warnings.body_too_long', 'The message is too long for the server (at most {max} bytes, formatting included).', { max: sizeLimits.body })
|
||||
: null;
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (endBeforeStart) warnings.push(t('settings.vacation.warnings.end_before_start', 'End date must be after start date'));
|
||||
if (startInPast) warnings.push(t('settings.vacation.warnings.start_in_past', 'Start date is in the past'));
|
||||
@@ -155,7 +179,8 @@ export function VacationSettings() {
|
||||
forwardChanged ||
|
||||
audienceChanged;
|
||||
|
||||
const canSave = hasChanges && !endBeforeStart && !formatError && !forwardInvalid && !store.isSaving;
|
||||
const canSave = hasChanges && !endBeforeStart && !formatError && !forwardInvalid
|
||||
&& !oversize.subject && !oversize.body && !store.isSaving;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Read the live editor DOM rather than trusting onChange state (issue #9).
|
||||
@@ -342,8 +367,9 @@ export function VacationSettings() {
|
||||
placeholder={t('settings.vacation.message.subject_placeholder', 'Out of Office')}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
accessibilityLabel={t('settings.vacation.message.subject_label', 'Subject')}
|
||||
style={styles.subjectInput}
|
||||
style={[styles.subjectInput, oversize.subject && styles.inputInvalid]}
|
||||
/>
|
||||
{subjectTooLong && <Text style={styles.fieldError}>{subjectTooLong}</Text>}
|
||||
</View>
|
||||
<View style={styles.bodyBlock}>
|
||||
<Text style={styles.bodyLabel}>{t('settings.vacation.message.body_label', 'Message Body')}</Text>
|
||||
@@ -353,11 +379,12 @@ export function VacationSettings() {
|
||||
onChangeText={setBody}
|
||||
placeholder={t('settings.vacation.message.body_placeholder', 'Thank you for your email. I am currently out of the office and will respond when I return.')}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.bodyInput}
|
||||
style={[styles.bodyInput, oversize.body && styles.inputInvalid]}
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{bodyTooLong && <Text style={styles.fieldError}>{bodyTooLong}</Text>}
|
||||
</View>
|
||||
<SettingItem
|
||||
label={t('settings.vacation.message.html_label', 'Formatted message (HTML)')}
|
||||
@@ -466,6 +493,11 @@ export function VacationSettings() {
|
||||
accessibilityLabel={t('settings.vacation.forward.keep_label', 'Keep in inbox')}
|
||||
/>
|
||||
</SettingItem>
|
||||
{forwardOverLimit && (
|
||||
<Text style={styles.forwardWarning}>
|
||||
{t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: forwardLimit })}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
@@ -570,6 +602,8 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.body,
|
||||
},
|
||||
inputInvalid: { borderColor: c.error },
|
||||
fieldError: { ...typography.caption, color: c.error, marginTop: spacing.xs },
|
||||
forwardWarning: { ...typography.caption, color: c.warning, paddingHorizontal: spacing.lg, paddingBottom: spacing.md },
|
||||
subjectInput: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
|
||||
Reference in New Issue
Block a user