mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
Merge remote-tracking branch 'origin/main' into fix/found-fix-server
# Conflicts: # Dockerfile # docker-compose.yml
This commit is contained in:
@@ -148,6 +148,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
|
||||
# Directory for credentials users save for plugins, encrypted (default:
|
||||
# plugin-secrets next to SETTINGS_DATA_DIR, so /app/data/plugin-secrets in
|
||||
# Docker) - mount a persistent volume there (see docker-compose.yml).
|
||||
# PLUGIN_SECRETS_DIR=./data/plugin-secrets
|
||||
|
||||
# =============================================================================
|
||||
# Admin Dashboard Data
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createHash } from 'crypto';
|
||||
import { initAdminPassword, verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password';
|
||||
import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP, isSameOriginRequest } from '@/lib/admin/session';
|
||||
import { checkRateLimit } from '@/lib/admin/rate-limit';
|
||||
import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP, isSameOriginRequest, getAdminDeviceId, setAdminDeviceCookie } from '@/lib/admin/session';
|
||||
import { checkRateLimit, checkDeviceRateLimit } from '@/lib/admin/rate-limit';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
@@ -160,6 +160,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
await setAdminSessionCookie(request);
|
||||
await setAdminDeviceCookie(request);
|
||||
await auditLog('admin.login', { method: 'stalwart' }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -170,8 +171,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
const limit = checkRateLimit(ip);
|
||||
// Rate limit check. A browser an admin signed in from before is limited
|
||||
// on its own, so others using up the shared budget can't lock it out.
|
||||
const deviceId = await getAdminDeviceId();
|
||||
const limit = deviceId ? checkDeviceRateLimit(deviceId) : checkRateLimit(ip);
|
||||
if (!limit.allowed) {
|
||||
const retryAfter = Math.ceil(limit.retryAfterMs / 1000);
|
||||
await auditLog('admin.login_blocked', { reason: 'rate_limit' }, ip);
|
||||
@@ -195,6 +198,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
await setAdminSessionCookie(request);
|
||||
await setAdminDeviceCookie(request);
|
||||
await updateLastLogin();
|
||||
await auditLog('admin.login', {}, ip);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EmailComposer } from '../email-composer';
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
takeOver: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
transform: vi.fn(async (email: unknown) => email),
|
||||
}));
|
||||
|
||||
// ─── Heavy component mocks (mirrors composer-from-override-envelope.test.tsx) ─
|
||||
@@ -158,7 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
|
||||
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
|
||||
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
|
||||
onComposeSend: { takeOver: hoisted.takeOver },
|
||||
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
|
||||
onTransformOutgoingEmail: { transform: hoisted.transform },
|
||||
},
|
||||
contactHooks: {
|
||||
search: { call: async () => [] },
|
||||
@@ -254,4 +255,16 @@ describe('composer send taken over by a plugin (#1207)', () => {
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('sends the headers a plugin added to the outgoing message', async () => {
|
||||
hoisted.takeOver.mockResolvedValue('default');
|
||||
hoisted.transform.mockImplementationOnce(async (email: unknown) => ({
|
||||
...(email as Record<string, unknown>),
|
||||
headers: { 'X-Classification': 'Internal' },
|
||||
}));
|
||||
const { onSend } = renderComposer();
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect(onSend.mock.calls[0][0].headers).toEqual({ 'X-Classification': 'Internal' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { remoteContentBlockedByOrg } from '@/hooks/use-third-party-mode';
|
||||
|
||||
// An organisation that locks the external content setting to "block" means
|
||||
// it: a sender the user trusted before (or trusts now) doesn't load remote
|
||||
// content, and nothing offers to trust or load.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
const { ThreadConversationView } = await import('../thread-conversation-view');
|
||||
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }],
|
||||
subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
|
||||
textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
|
||||
bodyValues: { '1': { value: '<p>Hello</p><img src="https://track.shop.example/pixel.gif">' } },
|
||||
} as unknown as Email;
|
||||
|
||||
const initialPolicy = usePolicyStore.getState().policy;
|
||||
|
||||
function lockExternalContent(restriction: Record<string, unknown>, defaults: Record<string, unknown> = {}) {
|
||||
usePolicyStore.setState({
|
||||
policy: {
|
||||
...initialPolicy,
|
||||
restrictions: { externalContentPolicy: restriction },
|
||||
defaults: { ...initialPolicy.defaults, ...defaults },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePolicyStore.setState({ policy: initialPolicy });
|
||||
useSettingsStore.setState({ externalContentPolicy: 'ask', trustedSenders: [] });
|
||||
});
|
||||
|
||||
async function renderViewer() {
|
||||
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
}
|
||||
|
||||
async function renderConversation() {
|
||||
const view = render(
|
||||
<ThreadConversationView thread={{ threadId: 't1', emails: [email], latestEmail: email } as never} emails={[email] as never} onBack={() => {}} />,
|
||||
);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
|
||||
return view;
|
||||
}
|
||||
|
||||
describe('external content locked to block', () => {
|
||||
it('a trusted sender still loads remote content when the user chose block', async () => {
|
||||
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: ['news@shop.example'] });
|
||||
await renderViewer();
|
||||
expect(screen.queryByTestId('external-content-notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a trusted sender blocked in the reading pane, with no trust button', async () => {
|
||||
lockExternalContent({ locked: true, value: 'block' });
|
||||
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: ['news@shop.example'] });
|
||||
await renderViewer();
|
||||
const notice = await screen.findByTestId('external-content-notice');
|
||||
expect(notice.textContent).toContain('remote_content_blocked');
|
||||
expect(screen.queryByText('trust_sender')).toBeNull();
|
||||
expect(screen.queryByText('load_external_content')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a trusted sender blocked in the conversation view, with nothing offered', async () => {
|
||||
lockExternalContent({ locked: true, value: 'block' });
|
||||
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: ['news@shop.example'] });
|
||||
const { container } = await renderConversation();
|
||||
expect(screen.getByTestId('external-content-notice').textContent).toContain('third_party.remote_content_blocked');
|
||||
expect(screen.queryByText('email_viewer.load_external_content')).toBeNull();
|
||||
expect(screen.queryByText('email_viewer.trust_sender')).toBeNull();
|
||||
const srcdoc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
|
||||
expect(srcdoc).not.toMatch(/\ssrc="https:\/\/track\.shop\.example/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remoteContentBlockedByOrg', () => {
|
||||
it('is true for a lock to block, also when the block comes from the defaults', () => {
|
||||
lockExternalContent({ locked: true, value: 'block' });
|
||||
expect(remoteContentBlockedByOrg()).toBe(true);
|
||||
lockExternalContent({ locked: true }, { externalContentPolicy: 'block' });
|
||||
expect(remoteContentBlockedByOrg()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a lock to ask, and for block without a lock', () => {
|
||||
lockExternalContent({ locked: true, value: 'ask' });
|
||||
expect(remoteContentBlockedByOrg()).toBe(false);
|
||||
lockExternalContent({ value: 'block' });
|
||||
expect(remoteContentBlockedByOrg()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,7 @@ import { personalTemplatesFor } from "@/lib/templates/personal-templates";
|
||||
import { notifyIdentitySelected } from "@/stores/identity-store";
|
||||
import { usePluginSignature } from "@/hooks/use-plugin-signature";
|
||||
import { useOrgSignature, useOrgSignatureIdentity } from "@/hooks/use-org-signature";
|
||||
import { remoteContentBlockedByOrg } from "@/hooks/use-third-party-mode";
|
||||
import { OrgDisclaimerPreview } from "@/components/email/org-disclaimer-preview";
|
||||
// Registers the organisation signature check and the disclaimer on the send path.
|
||||
import "@/lib/org/outgoing";
|
||||
@@ -296,6 +297,8 @@ interface EmailComposerProps {
|
||||
requireTls?: boolean;
|
||||
/** Importance / X-Priority; absent for normal (#970). */
|
||||
priority?: MessagePriority;
|
||||
/** Extra headers plugins added in onTransformOutgoingEmail. */
|
||||
headers?: Record<string, string>;
|
||||
/** The id plugins know this composer by, for onAfterEmailSend. */
|
||||
composerId?: string;
|
||||
}) => void | false | Promise<void | false>;
|
||||
@@ -745,7 +748,8 @@ export function EmailComposer({
|
||||
// Whether the quoted original may show its remote images in the editor:
|
||||
// the viewer's rule, minus the per-message "load" click it doesn't know.
|
||||
const { externalContentPolicy, isSenderTrusted } = useSettingsStore.getState();
|
||||
const quoteRemoteAllowed = externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email));
|
||||
const quoteRemoteAllowed = !remoteContentBlockedByOrg()
|
||||
&& (externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email)));
|
||||
|
||||
const replyHeader = (html: string) => mode === 'reply' || mode === 'replyAll'
|
||||
? markReplyQuoteHeader(html)
|
||||
@@ -3695,6 +3699,7 @@ export function EmailComposer({
|
||||
requestDsn: requestDsn || undefined,
|
||||
requireTls: requireTls || undefined,
|
||||
priority: priority !== 'normal' ? priority : undefined,
|
||||
headers: outgoing.headers,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
composerId: pluginComposerIdRef.current ?? undefined,
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
|
||||
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { useRemoteContentBlockedByOrg } from "@/hooks/use-third-party-mode";
|
||||
import { useSenderWarning } from "@/hooks/use-sender-warning";
|
||||
import { BrandVerifiedBadge } from "@/components/email/brand-verified-badge";
|
||||
|
||||
@@ -1313,9 +1313,9 @@ export function EmailViewer({
|
||||
const tWelcome = useTranslations('welcome');
|
||||
const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled);
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
// No third-party traffic (#903): remote content never loads, whatever the
|
||||
// policy or trust says, and nothing offers to load it.
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
// No third-party traffic (#903), or the organisation locked "block": remote
|
||||
// content never loads, whatever trust says, and nothing offers to load it.
|
||||
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
|
||||
const tThirdParty = useTranslations('third_party');
|
||||
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
|
||||
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
|
||||
|
||||
@@ -66,7 +66,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { useRemoteContentBlockedByOrg } from "@/hooks/use-third-party-mode";
|
||||
import { ConversationHeaderActions, type ConversationActions } from "./conversation-header-actions";
|
||||
|
||||
export type ConversationAttachmentHandler = (
|
||||
@@ -107,8 +107,9 @@ export function useConversationExternalContent() {
|
||||
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
|
||||
// No third-party traffic (#903): nothing remote loads and nothing offers to.
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
// No third-party traffic (#903), or the organisation locked "block":
|
||||
// nothing remote loads and nothing offers to.
|
||||
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
|
||||
|
||||
const allow = useCallback((emailId: string) => {
|
||||
setAllowedIds(prev => new Set(prev).add(emailId));
|
||||
@@ -542,7 +543,7 @@ export function ConversationEmailBody({
|
||||
bodyStyle,
|
||||
className,
|
||||
}: ConversationEmailBodyProps) {
|
||||
const remoteBlockedByOrg = useNoThirdPartyTraffic();
|
||||
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
|
||||
const t = useTranslations();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { FilterRuleModal } from '../filter-rule-modal';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
const rule: FilterRule = {
|
||||
id: 'r1',
|
||||
name: 'Cron mail',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'subject', comparator: 'contains', value: '' }],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
stopProcessing: false,
|
||||
};
|
||||
|
||||
function openModal(props: Partial<React.ComponentProps<typeof FilterRuleModal>> = {}) {
|
||||
const onSave = vi.fn();
|
||||
render(<FilterRuleModal mailboxes={[]} initialRule={rule} onSave={onSave} onClose={vi.fn()} {...props} />);
|
||||
return { onSave, saved: () => onSave.mock.calls[0][0] as FilterRule };
|
||||
}
|
||||
|
||||
const fieldSelect = () => screen.getByLabelText('conditions') as HTMLSelectElement;
|
||||
const comparatorSelect = () => screen.getByLabelText('comparators.contains') as HTMLSelectElement;
|
||||
const comparatorOptions = () => [...comparatorSelect().options].map((o) => o.value);
|
||||
const valueInput = () => screen.getByLabelText('condition_value') as HTMLInputElement;
|
||||
|
||||
describe('the "matches regex" comparator', () => {
|
||||
it('is offered only when the server lists the regex extension', () => {
|
||||
openModal();
|
||||
expect(comparatorOptions()).not.toContain('regex');
|
||||
});
|
||||
|
||||
it('is offered on header fields and the body, not on size or attachments', () => {
|
||||
openModal({ regexSupported: true });
|
||||
expect(comparatorOptions()).toContain('regex');
|
||||
fireEvent.change(fieldSelect(), { target: { value: 'body' } });
|
||||
expect(comparatorOptions()).toContain('regex');
|
||||
fireEvent.change(fieldSelect(), { target: { value: 'size' } });
|
||||
expect(comparatorOptions()).not.toContain('regex');
|
||||
});
|
||||
|
||||
it('keeps commas inside the expression instead of splitting it into values', () => {
|
||||
const { saved } = openModal({ regexSupported: true });
|
||||
fireEvent.change(comparatorSelect(), { target: { value: 'regex' } });
|
||||
fireEvent.change(valueInput(), { target: { value: '^Cron, job{1,3}' } });
|
||||
fireEvent.blur(valueInput());
|
||||
expect(valueInput().value).toBe('^Cron, job{1,3}');
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(saved().conditions).toEqual([{ field: 'subject', comparator: 'regex', value: '^Cron, job{1,3}' }]);
|
||||
});
|
||||
|
||||
it('does not save an expression the server would refuse', () => {
|
||||
const { onSave } = openModal({ regexSupported: true });
|
||||
fireEvent.change(comparatorSelect(), { target: { value: 'regex' } });
|
||||
fireEvent.change(valueInput(), { target: { value: '(unclosed' } });
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('validation_regex')).toBeTruthy();
|
||||
expect(valueInput().getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
it('stays on a saved regex rule when the server does not list the extension', () => {
|
||||
openModal({ initialRule: { ...rule, conditions: [{ field: 'subject', comparator: 'regex', value: '^Cron' }] } });
|
||||
expect(comparatorSelect().value).toBe('regex');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { isValidSieveHeaderName } from "@/lib/sieve/generator";
|
||||
import { REGEX_FIELDS, isValidSieveRegex, usesRegex } from "@/lib/sieve/regex";
|
||||
import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID, cn } from "@/lib/utils";
|
||||
import type { RuleSuggestion } from "@/lib/filters/quick-rules";
|
||||
import { retroactiveSupport } from "@/lib/filters/retroactive";
|
||||
@@ -50,6 +51,8 @@ interface FilterRuleModalProps {
|
||||
forwardsAfter?: number;
|
||||
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
||||
periodsSupported?: boolean;
|
||||
/** The server lists the Sieve "regex" extension. */
|
||||
regexSupported?: boolean;
|
||||
onSave: (rule: FilterRule, options?: { applyToExisting: boolean }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -72,7 +75,7 @@ const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"];
|
||||
// "All messages" has nothing to choose: its one comparator is not shown.
|
||||
const ALL_MESSAGES_COMPARATORS: FilterComparator[] = ["any"];
|
||||
|
||||
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
function baseComparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === "size") return SIZE_COMPARATORS;
|
||||
if (field === "attachment") return ATTACHMENT_COMPARATORS;
|
||||
if (field === "all") return ALL_MESSAGES_COMPARATORS;
|
||||
@@ -80,6 +83,13 @@ function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
|
||||
// "matches regex" only where the server can run it, or on a row that already
|
||||
// uses it, so editing such a rule does not change its comparator.
|
||||
function comparatorsFor(field: FilterConditionField, regex: boolean): FilterComparator[] {
|
||||
const base = baseComparatorsFor(field);
|
||||
return regex && REGEX_FIELDS.has(field) ? [...base, "regex"] : base;
|
||||
}
|
||||
|
||||
const ALL_ACTION_TYPES: FilterActionType[] = [
|
||||
"move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop",
|
||||
];
|
||||
@@ -117,6 +127,13 @@ function takesNoValue(c: Pick<FilterCondition, "field" | "comparator">): boolean
|
||||
return c.field === "all" || (c.field === "attachment" && c.comparator === "has_any");
|
||||
}
|
||||
|
||||
// A regex row whose expression (or one of them) would not compile.
|
||||
function regexInvalid(c: FilterCondition): boolean {
|
||||
if (!usesRegex(c) || isConditionValueEmpty(c.value)) return false;
|
||||
const values = Array.isArray(c.value) ? c.value : [c.value];
|
||||
return values.some((v) => !isValidSieveRegex(v));
|
||||
}
|
||||
|
||||
function makeEmptyAction(): FilterAction {
|
||||
return { type: "move", value: "" };
|
||||
}
|
||||
@@ -162,6 +179,7 @@ export function FilterRuleModal({
|
||||
forwardsBefore = 0,
|
||||
forwardsAfter = 0,
|
||||
periodsSupported = false,
|
||||
regexSupported = false,
|
||||
onSave,
|
||||
onClose,
|
||||
}: FilterRuleModalProps) {
|
||||
@@ -255,6 +273,9 @@ export function FilterRuleModal({
|
||||
if (takesNoValue(c)) return c;
|
||||
if (c.field === "size") return c; // numeric, single-value only
|
||||
if (typeof c.value !== "string") return c; // already structured
|
||||
// A comma belongs to the expression ("a{1,3}"); several patterns are
|
||||
// written with "|".
|
||||
if (usesRegex(c)) return c;
|
||||
const parsed = inputStringToValue(c.value);
|
||||
return { ...c, value: parsed };
|
||||
})
|
||||
@@ -306,7 +327,11 @@ export function FilterRuleModal({
|
||||
// it the whole script, so every other rule would stop working too.
|
||||
: validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? ""))
|
||||
? t("validation_header_name")
|
||||
: null,
|
||||
// The server compiles every expression on save and refuses the
|
||||
// whole script for one that does not compile.
|
||||
: validConditions.some(regexInvalid)
|
||||
? t("validation_regex")
|
||||
: null,
|
||||
// A row that is there but unfinished says what it misses; "at least one
|
||||
// action" is left for when no row has a type at all.
|
||||
actions: firstIncompleteAction
|
||||
@@ -339,6 +364,7 @@ export function FilterRuleModal({
|
||||
const headerInvalid = (c: FilterCondition) =>
|
||||
!!shown(errors.conditions) && c.field === "header" && !isConditionValueEmpty(c.value) &&
|
||||
!isValidSieveHeaderName((c.headerName ?? "").trim());
|
||||
const regexInvalidShown = (c: FilterCondition) => !!shown(errors.conditions) && regexInvalid(c);
|
||||
const actionInvalid = (index: number) => !!shown(errors.actions) && actionIncomplete[index];
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -394,7 +420,7 @@ export function FilterRuleModal({
|
||||
// with e.g. (field=attachment, comparator=contains) — invalid for the
|
||||
// Sieve generator. Each field has its own valid comparator set.
|
||||
if (updates.field && updates.field !== c.field) {
|
||||
const allowed = comparatorsFor(updates.field);
|
||||
const allowed = comparatorsFor(updates.field, regexSupported);
|
||||
if (!allowed.includes(c.comparator)) {
|
||||
updated.comparator = allowed[0];
|
||||
}
|
||||
@@ -579,7 +605,7 @@ export function FilterRuleModal({
|
||||
className={selectClass}
|
||||
aria-label={t("comparators.contains")}
|
||||
>
|
||||
{comparatorsFor(condition.field).map((c) => (
|
||||
{comparatorsFor(condition.field, regexSupported || condition.comparator === "regex").map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`comparators.${c}`)}
|
||||
</option>
|
||||
@@ -607,6 +633,7 @@ export function FilterRuleModal({
|
||||
// stays numeric/single-value; attachment-has_any has
|
||||
// no value at all.
|
||||
if (condition.field === "size") return;
|
||||
if (usesRegex(condition)) return;
|
||||
if (
|
||||
condition.field === "attachment" &&
|
||||
condition.comparator === "has_any"
|
||||
@@ -627,13 +654,15 @@ export function FilterRuleModal({
|
||||
? t("size_placeholder")
|
||||
: condition.field === "attachment"
|
||||
? t("attachment_type_placeholder")
|
||||
: t("value_placeholder_multi")
|
||||
: usesRegex(condition)
|
||||
? t("regex_placeholder")
|
||||
: t("value_placeholder_multi")
|
||||
}
|
||||
aria-label={t("condition_value")}
|
||||
aria-invalid={valueMissing(condition)}
|
||||
aria-describedby={valueMissing(condition) ? "rule-conditions-error" : undefined}
|
||||
data-rule-error={valueMissing(condition)}
|
||||
className={cn("flex-1 min-w-[120px]", valueMissing(condition) && "border-destructive")}
|
||||
aria-invalid={valueMissing(condition) || regexInvalidShown(condition)}
|
||||
aria-describedby={valueMissing(condition) || regexInvalidShown(condition) ? "rule-conditions-error" : undefined}
|
||||
data-rule-error={valueMissing(condition) || regexInvalidShown(condition)}
|
||||
className={cn("flex-1 min-w-[120px]", (valueMissing(condition) || regexInvalidShown(condition)) && "border-destructive")}
|
||||
type={condition.field === "size" ? "number" : "text"}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { readAccountFilters } from "@/lib/filters/account-filters";
|
||||
import { forwardsAround, inRunOrder } from "@/lib/filters/forward-limit";
|
||||
import { createFolderAndRunPreset, saveEditorRule } from "@/lib/filters/quick-rule-flow";
|
||||
import { supportsPeriods } from "@/lib/sieve/period";
|
||||
import { supportsRegex } from "@/lib/sieve/regex";
|
||||
|
||||
/**
|
||||
* Draws what a rule made from a message opens: the rule editor ("Create
|
||||
@@ -61,6 +62,7 @@ export function QuickRuleHost() {
|
||||
forwardsBefore={forwards.before}
|
||||
forwardsAfter={forwards.after}
|
||||
periodsSupported={supportsPeriods(editor.target.client.getSieveCapabilities(editor.target.sieveAccountId)?.sieveExtensions)}
|
||||
regexSupported={supportsRegex(editor.target.client.getSieveCapabilities(editor.target.sieveAccountId)?.sieveExtensions)}
|
||||
onSave={(rule, options) => {
|
||||
closeEditor();
|
||||
void saveEditorRule(editor, rule, options?.applyToExisting ?? false, text);
|
||||
|
||||
@@ -2154,6 +2154,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
requestDsn?: boolean;
|
||||
requireTls?: boolean;
|
||||
priority?: MessagePriority;
|
||||
headers?: Record<string, string>;
|
||||
composerId?: string;
|
||||
}) => {
|
||||
// Nothing is sent: the composer keeps what only goes once the mail is out.
|
||||
@@ -2178,7 +2179,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const effectiveMode = pendingDraft?.mode ?? composerMode;
|
||||
const originalEmailId = composerSourceEmail?.id;
|
||||
|
||||
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, localAccountId: data.localAccountId, draftAccountId: data.draftAccountId });
|
||||
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, headers: data.headers, localAccountId: data.localAccountId, draftAccountId: data.draftAccountId });
|
||||
submitted = true;
|
||||
announceSent(result, data);
|
||||
setShowComposer(false);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { FilterRule } from "@/lib/jmap/sieve-types";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { forwardsAround, inRunOrder, worstCaseForwards } from "@/lib/filters/forward-limit";
|
||||
import { isPeriodBoundary, periodStatus, supportsPeriods } from "@/lib/sieve/period";
|
||||
import { supportsRegex } from "@/lib/sieve/regex";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
@@ -787,6 +788,7 @@ export function FilterSettings() {
|
||||
mailboxes={mailboxes}
|
||||
maxRedirects={sieveCapabilities?.maxNumberRedirects}
|
||||
periodsSupported={supportsPeriods(sieveCapabilities?.sieveExtensions)}
|
||||
regexSupported={supportsRegex(sieveCapabilities?.sieveExtensions)}
|
||||
forwardsBefore={forwardsOfEdited.before}
|
||||
forwardsAfter={forwardsOfEdited.after}
|
||||
onSave={handleSaveRule}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
/**
|
||||
* Whether the browser must stay off built-in outside hosts (#903), as React
|
||||
@@ -11,3 +12,33 @@ import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-
|
||||
export function useNoThirdPartyTraffic(): boolean {
|
||||
return useSyncExternalStore(subscribeThirdPartyMode, noThirdPartyTraffic, () => true);
|
||||
}
|
||||
|
||||
type PolicyState = ReturnType<typeof usePolicyStore.getState>;
|
||||
|
||||
/** The external content setting locked to "block" by the organisation. */
|
||||
function externalContentLockedToBlock(state: PolicyState): boolean {
|
||||
const restriction = state.getRestriction('externalContentPolicy');
|
||||
if (restriction?.locked !== true) return false;
|
||||
// A lock without a value locks the organisation's default (as the
|
||||
// settings store applies it).
|
||||
const value = Object.prototype.hasOwnProperty.call(restriction, 'value')
|
||||
? restriction.value
|
||||
: state.getEffectiveDefault('externalContentPolicy');
|
||||
return value === 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the organisation keeps remote content in mail from loading at all:
|
||||
* no third-party traffic, or the external content setting locked to "block".
|
||||
* Then neither a one-off load nor a trusted sender lets it through.
|
||||
*/
|
||||
export function useRemoteContentBlockedByOrg(): boolean {
|
||||
const noThirdParty = useNoThirdPartyTraffic();
|
||||
const lockedToBlock = usePolicyStore(externalContentLockedToBlock);
|
||||
return noThirdParty || lockedToBlock;
|
||||
}
|
||||
|
||||
/** useRemoteContentBlockedByOrg, read once outside React. */
|
||||
export function remoteContentBlockedByOrg(): boolean {
|
||||
return noThirdPartyTraffic() || externalContentLockedToBlock(usePolicyStore.getState());
|
||||
}
|
||||
|
||||
@@ -48,10 +48,13 @@ vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: async () => (state.hasAdminSession ? { role: 'admin' } : { error: 'unauthorized' }),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
isSameOriginRequest: () => true,
|
||||
getAdminDeviceId: async () => null,
|
||||
setAdminDeviceCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/rate-limit', () => ({
|
||||
checkRateLimit: () => ({ allowed: true, retryAfterMs: 0 }),
|
||||
checkDeviceRateLimit: () => ({ allowed: true, retryAfterMs: 0 }),
|
||||
}));
|
||||
|
||||
const auditLog = vi.fn();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Admin password login allows 50 attempts across all clients per 15 minutes,
|
||||
// because a client without a proxy in front can make up its X-Forwarded-For
|
||||
// address. Anyone could spend that budget and lock every admin out. A browser
|
||||
// an admin signed in from before carries a device cookie and is limited on
|
||||
// its own (OWASP device cookies), so it still gets in.
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number }) => ({ json: async () => data, status: init?.status ?? 200 }),
|
||||
},
|
||||
}));
|
||||
|
||||
// One cookie jar per browser; `current` is the browser making the request.
|
||||
const browsers = { current: new Map<string, string>() };
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => (browsers.current.has(name) ? { name, value: browsers.current.get(name)! } : undefined),
|
||||
set: (name: string, value: string) => { browsers.current.set(name, value); },
|
||||
delete: (name: string) => { browsers.current.delete(name); },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() }));
|
||||
vi.mock('@/lib/security/same-origin', () => ({ isSameOriginRequest: () => true }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: vi.fn(), get: (_key: string, fallback?: unknown) => fallback },
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => null }));
|
||||
vi.mock('@/lib/admin/stalwart-admin-access', () => ({ getStalwartAdminAccessMode: async () => 'password' }));
|
||||
vi.mock('@/lib/admin/password', () => ({
|
||||
initAdminPassword: vi.fn().mockResolvedValue(true),
|
||||
isAdminEnabled: () => true,
|
||||
verifyAdminPassword: async (password: string) => password === 'right-password',
|
||||
updateLastLogin: vi.fn(),
|
||||
getAdminMeta: () => null,
|
||||
getAdminSessionGeneration: async () => 0,
|
||||
isAdminSessionRevoked: async () => false,
|
||||
revokeAdminSession: vi.fn(),
|
||||
}));
|
||||
|
||||
process.env.SESSION_SECRET = 'a'.repeat(48);
|
||||
|
||||
type Route = typeof import('@/app/api/admin/auth/route');
|
||||
let POST: Route['POST'];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh rate limiter state for every test.
|
||||
vi.resetModules();
|
||||
({ POST } = await import('@/app/api/admin/auth/route'));
|
||||
});
|
||||
|
||||
async function login(browser: Map<string, string>, password: string, ip: string): Promise<number> {
|
||||
browsers.current = browser;
|
||||
const request = new Request('https://bulwark.example/api/admin/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const response = await POST(request as never);
|
||||
return response.status;
|
||||
}
|
||||
|
||||
/** Wrong passwords from 50 made-up addresses: the whole shared budget. */
|
||||
async function spendSharedBudget() {
|
||||
const attacker = new Map<string, string>();
|
||||
for (let i = 0; i < 50; i++) await login(attacker, 'guess', `198.51.100.${i}`);
|
||||
expect(await login(attacker, 'guess', '198.51.100.200')).toBe(429);
|
||||
}
|
||||
|
||||
describe('admin login device cookie', () => {
|
||||
it('lets a browser an admin signed in from before in after others spent the shared budget', async () => {
|
||||
const admin = new Map<string, string>();
|
||||
expect(await login(admin, 'right-password', '203.0.113.7')).toBe(200);
|
||||
expect(admin.has('admin_device')).toBe(true);
|
||||
|
||||
await spendSharedBudget();
|
||||
|
||||
expect(await login(admin, 'right-password', '203.0.113.7')).toBe(200);
|
||||
});
|
||||
|
||||
it('still blocks a new browser once the shared budget is spent', async () => {
|
||||
await spendSharedBudget();
|
||||
expect(await login(new Map(), 'right-password', '203.0.113.8')).toBe(429);
|
||||
});
|
||||
|
||||
it('limits a device on its own to 5 attempts', async () => {
|
||||
const admin = new Map<string, string>();
|
||||
expect(await login(admin, 'right-password', '203.0.113.7')).toBe(200);
|
||||
for (let i = 0; i < 5; i++) expect(await login(admin, 'wrong', `192.0.2.${i}`)).toBe(401);
|
||||
expect(await login(admin, 'right-password', '192.0.2.99')).toBe(429);
|
||||
});
|
||||
|
||||
it('does not take an admin session token as a device cookie', async () => {
|
||||
const { createAdminSession, createAdminDeviceToken, verifyAdminDeviceToken, verifyAdminSession } = await import('@/lib/admin/session');
|
||||
expect(verifyAdminDeviceToken(createAdminSession())).toBeNull();
|
||||
expect(verifyAdminSession(createAdminDeviceToken())).toBeNull();
|
||||
expect(verifyAdminDeviceToken(createAdminDeviceToken())).toEqual(expect.any(String));
|
||||
|
||||
await spendSharedBudget();
|
||||
const forged = new Map([['admin_device', createAdminSession()]]);
|
||||
expect(await login(forged, 'right-password', '203.0.113.9')).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getConfigDir, getStateDir } from '@/lib/admin/paths';
|
||||
import { getSettingsDir } from '@/lib/settings-sync';
|
||||
import { getDir as getTelemetryDir } from '@/lib/telemetry/state';
|
||||
import { vaultDir } from '@/lib/plugin-secrets/vault';
|
||||
|
||||
// Every folder the server keeps state in by default must be a volume in the
|
||||
// shipped docker-compose.yml, or it is lost when the container is recreated,
|
||||
// and must exist in the image, or Docker creates the empty volume owned by
|
||||
// root and the app can't write to it. Plugin secrets were missing from both.
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const ENV_KEYS = [
|
||||
'ADMIN_CONFIG_DIR', 'ADMIN_DATA_DIR', 'ADMIN_STATE_DIR', 'SETTINGS_DATA_DIR',
|
||||
'TELEMETRY_DATA_DIR', 'PLUGIN_SECRETS_DIR',
|
||||
];
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/app');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** The default state folders inside the container, as the server resolves them. */
|
||||
function defaultStateDirs(): string[] {
|
||||
return [getConfigDir(), getStateDir(), getSettingsDir(), getTelemetryDir(), vaultDir()]
|
||||
.map((dir) => path.resolve(dir).replace(/\\/g, '/').replace(/^[A-Za-z]:/, ''));
|
||||
}
|
||||
|
||||
describe('docker data volumes', () => {
|
||||
it('resolves the plugin secrets folder inside /app/data', () => {
|
||||
expect(defaultStateDirs()).toContain('/app/data/plugin-secrets');
|
||||
});
|
||||
|
||||
it('mounts a volume on every default state folder in docker-compose.yml', () => {
|
||||
const compose = readFileSync(path.join(ROOT, 'docker-compose.yml'), 'utf8');
|
||||
const mounted = [...compose.matchAll(/^\s*-\s*[\w.-]+:(\/app\/[\w/.-]+?)(?::ro)?\s*$/gm)].map((m) => m[1]);
|
||||
for (const dir of defaultStateDirs()) expect(mounted).toContain(dir);
|
||||
});
|
||||
|
||||
it('declares every named volume the service uses', () => {
|
||||
const compose = readFileSync(path.join(ROOT, 'docker-compose.yml'), 'utf8');
|
||||
const used = [...compose.matchAll(/^\s*-\s*([\w-]+):\/app\//gm)].map((m) => m[1]);
|
||||
const declared = compose.split(/^volumes:\s*$/m)[1] ?? '';
|
||||
for (const name of used) expect(declared).toMatch(new RegExp(`^\\s+${name}:`, 'm'));
|
||||
});
|
||||
|
||||
it('creates every default state folder in the image', () => {
|
||||
const dockerfile = readFileSync(path.join(ROOT, 'Dockerfile'), 'utf8');
|
||||
const created = dockerfile.match(/RUN mkdir -p ([^&\n]+)/)?.[1].trim().split(/\s+/) ?? [];
|
||||
for (const dir of defaultStateDirs()) expect(created).toContain(dir);
|
||||
});
|
||||
});
|
||||
+31
-4
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* In-memory rate limiter for admin login.
|
||||
* Max 5 attempts per IP per 15 minutes, and 50 across all clients.
|
||||
* Max 5 attempts per IP per 15 minutes, and 50 across all clients. A browser
|
||||
* with the admin device cookie (lib/admin/session.ts) gets 5 per device
|
||||
* instead and stays out of the shared ceiling, which anyone can use up.
|
||||
*/
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
@@ -22,14 +24,17 @@ interface RateLimitEntry {
|
||||
}
|
||||
|
||||
const attempts = new Map<string, RateLimitEntry>();
|
||||
const deviceAttempts = new Map<string, RateLimitEntry>();
|
||||
let globalAttempts: RateLimitEntry | null = null;
|
||||
|
||||
// Clean up expired entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of attempts) {
|
||||
if (entry.resetAt <= now) {
|
||||
attempts.delete(key);
|
||||
for (const map of [attempts, deviceAttempts]) {
|
||||
for (const [key, entry] of map) {
|
||||
if (entry.resetAt <= now) {
|
||||
map.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 60_000).unref();
|
||||
@@ -64,3 +69,25 @@ export function checkRateLimit(ip: string): { allowed: boolean; remaining: numbe
|
||||
globalAttempts.count++;
|
||||
return { allowed: true, remaining: MAX_ATTEMPTS - entry.count, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a browser with a valid admin device cookie, by its device id: 5
|
||||
* attempts per 15 minutes, not counted against or stopped by the shared
|
||||
* ceiling.
|
||||
*/
|
||||
export function checkDeviceRateLimit(deviceId: string): { allowed: boolean; remaining: number; retryAfterMs: number } {
|
||||
const now = Date.now();
|
||||
const entry = deviceAttempts.get(deviceId);
|
||||
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
deviceAttempts.set(deviceId, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
if (entry.count >= MAX_ATTEMPTS) {
|
||||
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return { allowed: true, remaining: MAX_ATTEMPTS - entry.count, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
+97
-30
@@ -40,24 +40,12 @@ function getSessionTTL(): number {
|
||||
return isNaN(ttl) || ttl <= 0 ? DEFAULT_ADMIN_SESSION_TTL : ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an encrypted admin session token, in session generation
|
||||
* `generation` (see getAdminSessionGeneration).
|
||||
*/
|
||||
export function createAdminSession(generation = 0): string {
|
||||
/** Encrypt `payload` under the session key, bound to `aad`. */
|
||||
function sealToken(payload: object, aad: Buffer): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
cipher.setAAD(ADMIN_SESSION_AAD);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: AdminSessionPayload = {
|
||||
role: 'admin',
|
||||
iat: now,
|
||||
exp: now + getSessionTTL(),
|
||||
sid: randomBytes(16).toString('base64url'),
|
||||
gen: generation,
|
||||
};
|
||||
cipher.setAAD(aad);
|
||||
|
||||
const json = JSON.stringify(payload);
|
||||
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
|
||||
@@ -66,27 +54,48 @@ export function createAdminSession(generation = 0): string {
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
/** Decrypt a token sealToken made with the same `aad`. Throws when it doesn't open. */
|
||||
function openToken(token: string, aad: Buffer): unknown {
|
||||
const key = getKey();
|
||||
const data = Buffer.from(token, 'base64');
|
||||
if (data.length < IV_LENGTH + TAG_LENGTH) throw new Error('Token too short');
|
||||
|
||||
const iv = data.subarray(0, IV_LENGTH);
|
||||
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAAD(aad);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return JSON.parse(decrypted.toString('utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an encrypted admin session token, in session generation
|
||||
* `generation` (see getAdminSessionGeneration).
|
||||
*/
|
||||
export function createAdminSession(generation = 0): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: AdminSessionPayload = {
|
||||
role: 'admin',
|
||||
iat: now,
|
||||
exp: now + getSessionTTL(),
|
||||
sid: randomBytes(16).toString('base64url'),
|
||||
gen: generation,
|
||||
};
|
||||
return sealToken(payload, ADMIN_SESSION_AAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode an admin session token. Returns null if invalid or expired.
|
||||
*/
|
||||
export function verifyAdminSession(token: string): AdminSessionPayload | null {
|
||||
try {
|
||||
const key = getKey();
|
||||
const data = Buffer.from(token, 'base64');
|
||||
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
|
||||
const payload = openToken(token, ADMIN_SESSION_AAD) as AdminSessionPayload;
|
||||
|
||||
const iv = data.subarray(0, IV_LENGTH);
|
||||
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAAD(ADMIN_SESSION_AAD);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
const payload = JSON.parse(decrypted.toString('utf8')) as AdminSessionPayload;
|
||||
|
||||
if (payload.role !== 'admin') return null;
|
||||
if (!payload || payload.role !== 'admin') return null;
|
||||
// Every token this version issues has both; ADMIN_SESSION_AAD already
|
||||
// signed the ones from before out.
|
||||
if (typeof payload.sid !== 'string' || typeof payload.gen !== 'number') return null;
|
||||
@@ -167,6 +176,64 @@ export async function setAdminSessionCookie(request?: Request): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser that has signed in to the admin dashboard before (OWASP's
|
||||
* "device cookie"). Password attempts from it are limited per device instead
|
||||
* of by the budget every other client shares (lib/admin/rate-limit.ts), so
|
||||
* wrong passwords sent from anywhere else can't lock an admin's own browser
|
||||
* out. It grants nothing else. Sealed like the session token, under its own
|
||||
* associated data, so neither passes for the other.
|
||||
*/
|
||||
export const ADMIN_DEVICE_COOKIE = 'admin_device';
|
||||
const ADMIN_DEVICE_AAD = Buffer.from('bulwark-admin-device', 'utf8');
|
||||
const ADMIN_DEVICE_TTL = 180 * 24 * 60 * 60;
|
||||
|
||||
interface AdminDevicePayload {
|
||||
typ: 'admin-device';
|
||||
id: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export function createAdminDeviceToken(): string {
|
||||
const payload: AdminDevicePayload = {
|
||||
typ: 'admin-device',
|
||||
id: randomBytes(16).toString('base64url'),
|
||||
exp: Math.floor(Date.now() / 1000) + ADMIN_DEVICE_TTL,
|
||||
};
|
||||
return sealToken(payload, ADMIN_DEVICE_AAD);
|
||||
}
|
||||
|
||||
/** The device id in an admin device token, or null if it's invalid or expired. */
|
||||
export function verifyAdminDeviceToken(token: string): string | null {
|
||||
try {
|
||||
const payload = openToken(token, ADMIN_DEVICE_AAD) as AdminDevicePayload;
|
||||
if (!payload || payload.typ !== 'admin-device' || typeof payload.id !== 'string' || !payload.id) return null;
|
||||
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return payload.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The id of this browser's admin device cookie, if it has a valid one. */
|
||||
export async function getAdminDeviceId(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(ADMIN_DEVICE_COOKIE)?.value;
|
||||
return token ? verifyAdminDeviceToken(token) : null;
|
||||
}
|
||||
|
||||
/** Mark this browser as one an admin signed in from. Kept across sign-outs. */
|
||||
export async function setAdminDeviceCookie(request?: Request): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(ADMIN_DEVICE_COOKIE, createAdminDeviceToken(), {
|
||||
httpOnly: true,
|
||||
secure: secureCookieFlag(request),
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: ADMIN_DEVICE_TTL,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the admin session cookie and sign its token out, so a copy of the
|
||||
* cookie stops working too.
|
||||
|
||||
@@ -449,7 +449,7 @@ export interface IJMAPClient {
|
||||
// supportsSubmissionExtension).
|
||||
// `draftAccountId`: the account `draftId` lives in, so the sent draft is
|
||||
// destroyed there (default: the primary account).
|
||||
options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; draftAccountId?: string },
|
||||
options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; draftAccountId?: string },
|
||||
): Promise<SendEmailResult>;
|
||||
/** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */
|
||||
supportsSubmissionExtension?(extension: string, accountId?: string): boolean;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { parseAuthenticationResults } from "@/lib/email-headers";
|
||||
import { IS_LITE_STALWART } from "@/lib/lite";
|
||||
import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota";
|
||||
import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority";
|
||||
import { customHeaderProperties } from "@/lib/outgoing/custom-headers";
|
||||
import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body";
|
||||
|
||||
// Cap for the follow-up Email/get issued when a displayed body part comes
|
||||
@@ -1420,6 +1421,8 @@ export interface SubmissionSendOptions {
|
||||
requireTls?: boolean;
|
||||
/** Importance / X-Priority headers (#970); see lib/message-priority.ts. */
|
||||
priority?: MessagePriority;
|
||||
/** Extra headers plugins added; see lib/outgoing/custom-headers.ts. */
|
||||
headers?: Record<string, string>;
|
||||
/** The account the replaced draft (`draftId`) lives in; default the primary. */
|
||||
draftAccountId?: string;
|
||||
}
|
||||
@@ -4943,6 +4946,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
Object.assign(emailCreate, messagePriorityHeaders(options?.priority));
|
||||
Object.assign(emailCreate, customHeaderProperties(options?.headers));
|
||||
|
||||
// Images the HTML shows by cid: go into a multipart/related beside it,
|
||||
// or recipients don't see them (#1451).
|
||||
|
||||
@@ -26,6 +26,9 @@ export type FilterComparator =
|
||||
| 'is' | 'not_is'
|
||||
| 'starts_with' | 'ends_with'
|
||||
| 'matches'
|
||||
// Sieve `:regex` (the "regex" extension), on header fields and the body.
|
||||
// The value is one regular expression; commas in it are not separators.
|
||||
| 'regex'
|
||||
| 'greater_than' | 'less_than'
|
||||
// For field === 'attachment':
|
||||
// has_any → message has any attachment (Content-Disposition: attachment)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { customHeaderProperties } from '../custom-headers';
|
||||
|
||||
// Headers a plugin adds in onTransformOutgoingEmail (a classification label,
|
||||
// for one) go out with the message. Ones that would change the sender, clash
|
||||
// with what the client writes itself or forge trace headers don't.
|
||||
|
||||
describe('customHeaderProperties', () => {
|
||||
it('turns plugin headers into JMAP header properties', () => {
|
||||
expect(customHeaderProperties({ 'X-Classification': 'Confidential', Sensitivity: 'Company-Confidential' })).toEqual({
|
||||
'header:X-Classification:asText': 'Confidential',
|
||||
'header:Sensitivity:asText': 'Company-Confidential',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops headers that say who sent the mail, how it travelled, or that the client writes', () => {
|
||||
expect(customHeaderProperties({
|
||||
From: 'boss@example.com',
|
||||
sender: 'boss@example.com',
|
||||
'Reply-To': 'x@evil.example',
|
||||
Bcc: 'spy@evil.example',
|
||||
'Message-ID': '<x@example.com>',
|
||||
'Content-Type': 'text/plain',
|
||||
Received: 'from somewhere',
|
||||
'Authentication-Results': 'mx; dkim=pass',
|
||||
'DKIM-Signature': 'v=1',
|
||||
'ARC-Seal': 'i=1',
|
||||
'Resent-From': 'a@example.com',
|
||||
'X-Priority': '1',
|
||||
Importance: 'high',
|
||||
'Disposition-Notification-To': 'me@example.com',
|
||||
})).toEqual({});
|
||||
});
|
||||
|
||||
it('drops values with line breaks or control characters', () => {
|
||||
expect(customHeaderProperties({
|
||||
'X-Label': 'ok\r\nBcc: spy@evil.example',
|
||||
'X-Other': 'a\nb',
|
||||
'X-Nul': 'a\u0000b',
|
||||
})).toEqual({});
|
||||
});
|
||||
|
||||
it('drops invalid names, empty or non-text values and repeats in another case', () => {
|
||||
expect(customHeaderProperties({
|
||||
'X Bad': 'space',
|
||||
'X:Bad': 'colon',
|
||||
'X-Ümlaut': 'non-ascii',
|
||||
'X-Empty': ' ',
|
||||
'X-Number': 5 as unknown as string,
|
||||
'X-Label': 'first',
|
||||
'x-label': 'second',
|
||||
})).toEqual({ 'header:X-Label:asText': 'first' });
|
||||
});
|
||||
|
||||
it('ignores anything that is not a plain header map', () => {
|
||||
expect(customHeaderProperties(undefined)).toEqual({});
|
||||
expect(customHeaderProperties(['X-A'])).toEqual({});
|
||||
expect(customHeaderProperties('X-A: b')).toEqual({});
|
||||
});
|
||||
|
||||
it('caps the number of headers', () => {
|
||||
const many = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`X-H${i}`, 'v']));
|
||||
expect(Object.keys(customHeaderProperties(many))).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendEmail with plugin headers', () => {
|
||||
interface CapturedRequest { methodCalls: Array<[string, Record<string, unknown>, string]> }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
function mockFlow() {
|
||||
const captured: CapturedRequest[] = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
|
||||
captured.push(body);
|
||||
const methodResponses: unknown[] = [];
|
||||
for (const [method, args, callId] of body.methodCalls) {
|
||||
if (method === 'Mailbox/get') {
|
||||
methodResponses.push(['Mailbox/get', {
|
||||
list: [
|
||||
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
|
||||
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
|
||||
],
|
||||
}, callId]);
|
||||
} else if (method === 'Identity/get') {
|
||||
methodResponses.push(['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, callId]);
|
||||
} else if (method === 'Email/set') {
|
||||
const key = Object.keys((args.create ?? {}) as Record<string, unknown>)[0];
|
||||
methodResponses.push(['Email/set', key ? { created: { [key]: { id: 'email-new-1' } } } : {}, callId]);
|
||||
} else if (method === 'EmailSubmission/set') {
|
||||
methodResponses.push(['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, callId]);
|
||||
}
|
||||
}
|
||||
const payload = { methodResponses };
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify(payload)),
|
||||
json: () => Promise.resolve(payload),
|
||||
} as Response;
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
it('puts the plugin headers on the created message and leaves the sender alone', async () => {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||
Object.assign(client, { apiUrl: 'https://jmap.example.com/api', accountId: 'account-1', username: 'user@example.com' });
|
||||
const captured = mockFlow();
|
||||
await client.sendEmail(
|
||||
['to@example.com'], 'Subject', 'body',
|
||||
undefined, undefined, 'identity-1', 'user@example.com',
|
||||
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
{ headers: { 'X-Classification': 'Internal', From: 'boss@example.com' } },
|
||||
);
|
||||
const call = captured.flatMap(r => r.methodCalls).find(c => c[0] === 'Email/set' && c[1].create);
|
||||
const created = Object.values(call![1].create as Record<string, Record<string, unknown>>)[0];
|
||||
expect(created['header:X-Classification:asText']).toBe('Internal');
|
||||
expect(created['header:From:asText']).toBeUndefined();
|
||||
expect(created.from).toEqual([{ email: 'user@example.com' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
/**
|
||||
* Extra headers plugins add to an outgoing message (OutgoingEmail.headers),
|
||||
* such as a classification label, as JMAP Email/set create properties
|
||||
* (RFC 8621 §4.1.3, `header:Name:asText`, which the server encodes and folds).
|
||||
*
|
||||
* Only headers of their own get through. Names the client writes from other
|
||||
* properties are dropped, because JMAP refuses a create that sets the same
|
||||
* header twice, and so are names that say who sent the mail or how it
|
||||
* travelled: a plugin must not change the sender or forge trace and
|
||||
* authentication headers.
|
||||
*/
|
||||
|
||||
const RESERVED = new Set([
|
||||
'from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'subject', 'date',
|
||||
'message-id', 'in-reply-to', 'references', 'mime-version',
|
||||
'return-path', 'received', 'received-spf', 'authentication-results',
|
||||
'dkim-signature', 'domainkey-signature',
|
||||
'disposition-notification-to', 'importance', 'x-priority', 'priority',
|
||||
]);
|
||||
const RESERVED_PREFIXES = ['content-', 'resent-', 'arc-'];
|
||||
|
||||
/** RFC 5322 §3.6.8 field-name: printable US-ASCII except the colon. */
|
||||
const FIELD_NAME = /^[\x21-\x39\x3b-\x7e]{1,76}$/;
|
||||
/** Line breaks or other control characters but tab (header injection). */
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if ((code < 0x20 && code !== 0x09) || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const MAX_HEADERS = 20;
|
||||
// Room for an Autocrypt header with a whole key in it; the server folds it.
|
||||
const MAX_VALUE_LENGTH = 8192;
|
||||
|
||||
function isReserved(lowerName: string): boolean {
|
||||
return RESERVED.has(lowerName) || RESERVED_PREFIXES.some((prefix) => lowerName.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function customHeaderProperties(headers: unknown): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return out;
|
||||
const seen = new Set<string>();
|
||||
for (const [name, raw] of Object.entries(headers as Record<string, unknown>)) {
|
||||
const lower = name.toLowerCase();
|
||||
if (!FIELD_NAME.test(name) || isReserved(lower) || seen.has(lower)) {
|
||||
debug.warn(`Outgoing header "${name}" from a plugin was not sent`);
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== 'string') continue;
|
||||
const value = raw.trim();
|
||||
if (!value) continue;
|
||||
if (hasControlCharacter(value) || value.length > MAX_VALUE_LENGTH) {
|
||||
debug.warn(`Outgoing header "${name}" from a plugin was not sent: invalid value`);
|
||||
continue;
|
||||
}
|
||||
if (seen.size >= MAX_HEADERS) {
|
||||
debug.warn(`Outgoing header "${name}" from a plugin was not sent: more than ${MAX_HEADERS} headers`);
|
||||
continue;
|
||||
}
|
||||
seen.add(lower);
|
||||
out[`header:${name}:asText`] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type VaultData = Record<string, Record<string, StoredCredential>>;
|
||||
/** Cap on credentials per user, across all plugins. */
|
||||
export const MAX_USER_CREDENTIALS = 64;
|
||||
|
||||
function vaultDir(): string {
|
||||
export function vaultDir(): string {
|
||||
return process.env.PLUGIN_SECRETS_DIR
|
||||
|| path.join(process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings'), '..', 'plugin-secrets');
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function getKey(): Buffer {
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
function getSettingsDir(): string {
|
||||
export function getSettingsDir(): string {
|
||||
return process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateScript } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
import { isValidSieveRegex, supportsRegex } from '../regex';
|
||||
import type { FilterCondition, FilterRule } from '@/lib/jmap/sieve-types';
|
||||
|
||||
function makeRule(conditions: FilterCondition[], overrides: Partial<FilterRule> = {}): FilterRule {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
name: 'Cron mail',
|
||||
enabled: true,
|
||||
matchType: 'any',
|
||||
conditions,
|
||||
actions: [{ type: 'move', value: 'wartung' }],
|
||||
stopProcessing: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function requireLine(script: string): string {
|
||||
return script.split('\n').find((l) => l.startsWith('require ')) ?? '';
|
||||
}
|
||||
|
||||
function metadataVersion(script: string): number {
|
||||
const json = script.split('\n')[1];
|
||||
return JSON.parse(json).version;
|
||||
}
|
||||
|
||||
describe('regex conditions in the generator', () => {
|
||||
it('writes header :regex and requires "regex"', () => {
|
||||
const script = generateScript([
|
||||
makeRule([{ field: 'subject', comparator: 'regex', value: '^Cron.*|^Anacron.*Job.*|.*mailman.*moderator.*request.*' }]),
|
||||
]);
|
||||
expect(script).toContain('if header :regex "Subject" "^Cron.*|^Anacron.*Job.*|.*mailman.*moderator.*request.*" {');
|
||||
expect(requireLine(script)).toContain('"regex"');
|
||||
});
|
||||
|
||||
it('doubles backslashes so Sieve hands the server the expression as typed', () => {
|
||||
const script = generateScript([
|
||||
makeRule([{ field: 'from', comparator: 'regex', value: '@(onedomain\\.com|otherdomain\\.de)' }]),
|
||||
]);
|
||||
expect(script).toContain('header :regex "From" "@(onedomain\\\\.com|otherdomain\\\\.de)"');
|
||||
});
|
||||
|
||||
it('writes a custom header and the body with :regex', () => {
|
||||
const script = generateScript([
|
||||
makeRule([
|
||||
{ field: 'header', headerName: 'List-Id', comparator: 'regex', value: 'dev-[0-9]+' },
|
||||
{ field: 'body', comparator: 'regex', value: 'inv[0-9]{4,6}' },
|
||||
]),
|
||||
]);
|
||||
expect(script).toContain('header :regex "List-Id" "dev-[0-9]+"');
|
||||
expect(script).toContain('body :regex "inv[0-9]{4,6}"');
|
||||
expect(requireLine(script)).toContain('"body"');
|
||||
expect(requireLine(script)).toContain('"regex"');
|
||||
});
|
||||
|
||||
it('requires "regex" only when an enabled rule uses it', () => {
|
||||
const plain = generateScript([makeRule([{ field: 'subject', comparator: 'contains', value: 'Cron' }])]);
|
||||
expect(requireLine(plain)).not.toContain('"regex"');
|
||||
const disabled = generateScript([
|
||||
makeRule([{ field: 'subject', comparator: 'regex', value: '^Cron' }], { enabled: false }),
|
||||
]);
|
||||
expect(requireLine(disabled)).not.toContain('"regex"');
|
||||
expect(disabled).not.toContain(':regex');
|
||||
});
|
||||
|
||||
it('marks the metadata version 2 so older builds leave the rule alone', () => {
|
||||
expect(metadataVersion(generateScript([makeRule([{ field: 'subject', comparator: 'contains', value: 'Cron' }])]))).toBe(1);
|
||||
expect(metadataVersion(generateScript([makeRule([{ field: 'subject', comparator: 'regex', value: '^Cron' }])]))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex conditions in the parser', () => {
|
||||
it('reads a saved regex rule back unchanged', () => {
|
||||
const rule = makeRule([
|
||||
{ field: 'from', comparator: 'regex', value: '@(onedomain\\.com|otherdomain\\.de)' },
|
||||
{ field: 'body', comparator: 'regex', value: 'a{1,3}' },
|
||||
]);
|
||||
const parsed = parseScript(generateScript([rule]));
|
||||
expect(parsed.isOpaque).toBe(false);
|
||||
expect(parsed.rules[0].conditions).toEqual(rule.conditions);
|
||||
});
|
||||
|
||||
it('imports a hand-written header :regex rule as a regex condition', () => {
|
||||
const parsed = parseScript([
|
||||
'require ["fileinto", "regex"];',
|
||||
'if anyof(header :regex "Subject" "^Cron.*|^Anacron.*Job.*") {',
|
||||
' fileinto "wartung";',
|
||||
' stop;',
|
||||
'}',
|
||||
].join('\n'));
|
||||
expect(parsed.isOpaque).toBe(false);
|
||||
expect(parsed.rules[0].conditions).toEqual([
|
||||
{ field: 'subject', comparator: 'regex', value: '^Cron.*|^Anacron.*Job.*' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not read "not header :regex" as a positive match', () => {
|
||||
const parsed = parseScript([
|
||||
'require ["fileinto", "regex"];',
|
||||
'if not header :regex "Subject" "^Cron" {',
|
||||
' fileinto "wartung";',
|
||||
'}',
|
||||
].join('\n'));
|
||||
const conditions = parsed.rules.flatMap((r) => r.conditions);
|
||||
expect(conditions).not.toContainEqual({ field: 'subject', comparator: 'regex', value: '^Cron' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex helpers', () => {
|
||||
it('needs the server to list the extension', () => {
|
||||
expect(supportsRegex(['fileinto', 'regex'])).toBe(true);
|
||||
expect(supportsRegex(['fileinto'])).toBe(false);
|
||||
expect(supportsRegex(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts expressions the server compiles, including Rust inline flags', () => {
|
||||
expect(isValidSieveRegex('^Cron.*|mailman')).toBe(true);
|
||||
expect(isValidSieveRegex('a{1,3}')).toBe(true);
|
||||
expect(isValidSieveRegex('(?i)^cron')).toBe(true);
|
||||
expect(isValidSieveRegex('(?i:cron)job')).toBe(true);
|
||||
expect(isValidSieveRegex('(?P<user>[a-z]+)@')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects expressions the server refuses', () => {
|
||||
expect(isValidSieveRegex('(unclosed')).toBe(false);
|
||||
expect(isValidSieveRegex('[abc')).toBe(false);
|
||||
expect(isValidSieveRegex('*abc')).toBe(false);
|
||||
expect(isValidSieveRegex('')).toBe(false);
|
||||
});
|
||||
});
|
||||
+13
-3
@@ -3,6 +3,7 @@ import { debug } from '@/lib/debug';
|
||||
import { PERIOD_REQUIRES, hasPeriod, periodTests } from '@/lib/sieve/period';
|
||||
import { VACATION_FORWARD_MARKER, isValidVacationForward, normalizeVacationForward } from '@/lib/sieve/vacation-forward';
|
||||
import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience';
|
||||
import { usesRegex } from '@/lib/sieve/regex';
|
||||
|
||||
const HEADER_MAP: Record<string, string> = {
|
||||
from: 'From',
|
||||
@@ -73,7 +74,7 @@ function generateCondition(condition: FilterCondition): string {
|
||||
const values = toValueList(value);
|
||||
|
||||
if (field === 'body') {
|
||||
const matchType = comparator === 'is' ? ':is' : ':contains';
|
||||
const matchType = comparator === 'is' ? ':is' : comparator === 'regex' ? ':regex' : ':contains';
|
||||
return `body ${matchType} ${formatStringArg(values)}`;
|
||||
}
|
||||
|
||||
@@ -123,6 +124,8 @@ function generateCondition(condition: FilterCondition): string {
|
||||
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
|
||||
case 'matches':
|
||||
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
||||
case 'regex':
|
||||
return `header :regex "${headerName}" ${formatStringArg(values)}`;
|
||||
default:
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
}
|
||||
@@ -219,6 +222,7 @@ function computeRequires(
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
if (condition.field === 'attachment') extensions.add('mime');
|
||||
if (usesRegex(condition)) extensions.add('regex');
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
switch (action.type) {
|
||||
@@ -342,8 +346,14 @@ export function generateScript(
|
||||
// without what it does not know: a rule without its period forwards for
|
||||
// good, the auto-reply goes to everyone. It leaves version 2 alone. A
|
||||
// forward that is off only loses its remembered address that way, so it
|
||||
// stays version 1: the native app still edits version 1 only.
|
||||
if (metadata.rules.some(hasPeriod) || metadata.vacationForward?.enabled || metadata.vacationAudience) {
|
||||
// stays version 1: the native app still edits version 1 only. A regex
|
||||
// condition would come back from those as a plain "contains".
|
||||
if (
|
||||
metadata.rules.some(hasPeriod) ||
|
||||
metadata.rules.some(r => r.conditions.some(usesRegex)) ||
|
||||
metadata.vacationForward?.enabled ||
|
||||
metadata.vacationAudience
|
||||
) {
|
||||
metadata.version = 2;
|
||||
}
|
||||
// The JSON sits inside a /* ... */ comment: a "*/" in any string (a rule
|
||||
|
||||
+9
-3
@@ -497,11 +497,13 @@ function parseAtom(raw: string): FilterCondition | null {
|
||||
return { field, comparator: part === ':domain' ? 'domain_is' : 'address_is', value };
|
||||
}
|
||||
|
||||
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||
m = /^header\s+:(contains|is|matches|regex)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawTail] = m;
|
||||
const value = parseValueTail(rawTail);
|
||||
if (value === null) return null;
|
||||
// The builder has no "does not match regex".
|
||||
if (tag === 'regex' && negated) return null;
|
||||
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
|
||||
|
||||
let comparator: FilterComparator;
|
||||
@@ -512,6 +514,9 @@ function parseAtom(raw: string): FilterCondition | null {
|
||||
} else if (tag === 'is') {
|
||||
comparator = negated ? 'not_is' : 'is';
|
||||
finalValue = value;
|
||||
} else if (tag === 'regex') {
|
||||
comparator = 'regex';
|
||||
finalValue = value;
|
||||
} else {
|
||||
const classified = classifyMatches(value);
|
||||
comparator = classified.comparator;
|
||||
@@ -523,11 +528,12 @@ function parseAtom(raw: string): FilterCondition | null {
|
||||
return cond;
|
||||
}
|
||||
|
||||
m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
|
||||
m = /^body\s+:(contains|is|regex)\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const value = parseValueTail(m[2]);
|
||||
if (value === null) return null;
|
||||
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
|
||||
const comparator = m[1] === 'is' ? 'is' : m[1] === 'regex' ? 'regex' : 'contains';
|
||||
return { field: 'body', comparator, value };
|
||||
}
|
||||
|
||||
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FilterCondition, FilterConditionField } from '@/lib/jmap/sieve-types';
|
||||
|
||||
/**
|
||||
* Regular expression conditions: Sieve `:regex` from the "regex" extension
|
||||
* (draft-ietf-sieve-regex). Stalwart lists it in the account's
|
||||
* sieveExtensions and compiles the expression when the script is saved, so
|
||||
* one broken expression makes the server refuse the whole script.
|
||||
*/
|
||||
|
||||
/** Fields the generator writes `:regex` for: header tests and `body`. */
|
||||
export const REGEX_FIELDS: ReadonlySet<FilterConditionField> = new Set([
|
||||
'from', 'to', 'cc', 'subject', 'header', 'body',
|
||||
]);
|
||||
|
||||
export function supportsRegex(extensions: readonly string[] | undefined | null): boolean {
|
||||
return !!extensions?.includes('regex');
|
||||
}
|
||||
|
||||
export function usesRegex(condition: Pick<FilterCondition, 'field' | 'comparator'>): boolean {
|
||||
return condition.comparator === 'regex' && REGEX_FIELDS.has(condition.field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `pattern` is likely to compile on the server. Stalwart uses Rust
|
||||
* regex syntax, so its inline flags ("(?i)", "(?s:...)") and "(?P<name>"
|
||||
* groups are rewritten into something JavaScript parses before the check.
|
||||
* JavaScript accepts a few things Rust does not; the server still has the
|
||||
* last word on those.
|
||||
*/
|
||||
export function isValidSieveRegex(pattern: string): boolean {
|
||||
if (!pattern) return false;
|
||||
const js = pattern
|
||||
.replace(/\(\?[a-zA-Z]+\)/g, '')
|
||||
.replace(/\(\?[a-zA-Z-]+:/g, '(?:')
|
||||
.replace(/\(\?P</g, '(?<');
|
||||
try {
|
||||
new RegExp(js);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { TelemetryStateFile, ConsentState } from './types';
|
||||
import { DEFAULT_ENDPOINT } from './types';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
|
||||
function getDir(): string {
|
||||
export function getDir(): string {
|
||||
return process.env.TELEMETRY_DATA_DIR ||
|
||||
path.join(process.cwd(), 'data', 'telemetry');
|
||||
}
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "نقل الرسائل المصنفة كرسائل مزعجة أيضًا",
|
||||
"attachment_type_placeholder": "مثال: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "القيمة (عدة قيم مفصولة بفواصل)",
|
||||
"regex_placeholder": "تعبير نمطي، مثل ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "المرفق",
|
||||
"from": "من",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "يبدأ بـ",
|
||||
"ends_with": "ينتهي بـ",
|
||||
"matches": "يطابق النمط",
|
||||
"regex": "يطابق التعبير النمطي",
|
||||
"greater_than": "أكبر من",
|
||||
"less_than": "أصغر من",
|
||||
"address_is": "هو العنوان",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "أدخل عنوانًا لإعادة التوجيه إليه",
|
||||
"validation_action_reject": "أدخل رسالة الرفض",
|
||||
"validation_header_name": "يمكن أن يحتوي اسم الترويسة على الأحرف A-Z والأرقام والشرطات والشرطات السفلية فقط",
|
||||
"validation_regex": "هذا التعبير النمطي غير صالح",
|
||||
"templates_section": "البدء من قالب",
|
||||
"template_newsletters": "نقل النشرات البريدية إلى مجلد",
|
||||
"template_receipts": "أرشفة الإيصالات تلقائيًا",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "Mou també els missatges marcats com a correu brossa",
|
||||
"attachment_type_placeholder": "p. ex. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valor (múltiples separats per comes)",
|
||||
"regex_placeholder": "Expressió regular, p. ex. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Fitxer adjunt",
|
||||
"from": "De",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "comença per",
|
||||
"ends_with": "acaba amb",
|
||||
"matches": "coincideix amb el patró",
|
||||
"regex": "coincideix amb l'expressió regular",
|
||||
"greater_than": "és més gran que",
|
||||
"less_than": "és menys que",
|
||||
"address_is": "és l'adreça",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "Introduïu una adreça on reenviar",
|
||||
"validation_action_reject": "Introduïu el missatge de rebuig",
|
||||
"validation_header_name": "Els noms de capçalera només poden contenir les lletres A-Z, xifres, guionets i guions baixos",
|
||||
"validation_regex": "Aquesta expressió regular no és vàlida",
|
||||
"templates_section": "Comença des d'una plantilla",
|
||||
"template_newsletters": "Mou els butlletins a una carpeta",
|
||||
"template_receipts": "Arxiva automàticament els rebuts",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Přesouvat i zprávy označené jako spam",
|
||||
"attachment_type_placeholder": "např. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Hodnota (více oddělených čárkami)",
|
||||
"regex_placeholder": "Regulární výraz, např. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Příloha",
|
||||
"from": "Od",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "začíná na",
|
||||
"ends_with": "končí na",
|
||||
"matches": "odpovídá vzoru",
|
||||
"regex": "odpovídá regulárnímu výrazu",
|
||||
"greater_than": "je větší než",
|
||||
"less_than": "je menší než",
|
||||
"address_is": "je adresa",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Zadejte adresu, na kterou se má přeposílat",
|
||||
"validation_action_reject": "Zadejte zprávu o odmítnutí",
|
||||
"validation_header_name": "Názvy hlaviček smí obsahovat jen písmena A-Z, číslice, pomlčky a podtržítka",
|
||||
"validation_regex": "Tento regulární výraz není platný",
|
||||
"templates_section": "Začít ze šablony",
|
||||
"template_newsletters": "Přesunout newslettery do složky",
|
||||
"template_receipts": "Automaticky archivovat účtenky",
|
||||
|
||||
@@ -2678,6 +2678,7 @@
|
||||
"include_spam": "Flyt også beskeder, der er markeret som spam",
|
||||
"attachment_type_placeholder": "f.eks. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Værdi (flere adskilt med komma)",
|
||||
"regex_placeholder": "Regulært udtryk, f.eks. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Vedhæftet fil",
|
||||
"from": "Fra",
|
||||
@@ -2699,6 +2700,7 @@
|
||||
"starts_with": "begynder med",
|
||||
"ends_with": "slutter med",
|
||||
"matches": "matcher mønster",
|
||||
"regex": "matcher regulært udtryk",
|
||||
"greater_than": "er større end",
|
||||
"less_than": "er mindre end",
|
||||
"address_is": "er adressen",
|
||||
@@ -2761,6 +2763,7 @@
|
||||
"validation_action_forward": "Angiv en adresse, der skal videresendes til",
|
||||
"validation_action_reject": "Angiv afvisningsbeskeden",
|
||||
"validation_header_name": "Headernavne må kun indeholde bogstaverne A-Z, cifre, bindestreger og understregninger",
|
||||
"validation_regex": "Dette regulære udtryk er ugyldigt",
|
||||
"templates_section": "Start fra skabelon",
|
||||
"template_newsletters": "Flyt nyhedsbreve til mappe",
|
||||
"template_receipts": "Auto-arkiver kvitteringer",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Auch als Spam markierte Nachrichten verschieben",
|
||||
"attachment_type_placeholder": "z.B. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Wert (mehrere mit Komma trennen)",
|
||||
"regex_placeholder": "Regulärer Ausdruck, z. B. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Anhang",
|
||||
"from": "Von",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "beginnt mit",
|
||||
"ends_with": "endet mit",
|
||||
"matches": "entspricht dem Muster",
|
||||
"regex": "passt zum regulären Ausdruck",
|
||||
"greater_than": "ist größer als",
|
||||
"less_than": "ist kleiner als",
|
||||
"address_is": "ist die Adresse",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Geben Sie eine Adresse zum Weiterleiten ein",
|
||||
"validation_action_reject": "Geben Sie die Ablehnungsnachricht ein",
|
||||
"validation_header_name": "Header-Namen dürfen nur die Buchstaben A-Z, Ziffern, Bindestriche und Unterstriche enthalten",
|
||||
"validation_regex": "Dieser reguläre Ausdruck ist ungültig",
|
||||
"templates_section": "Von Vorlage starten",
|
||||
"template_newsletters": "Newsletter in Ordner verschieben",
|
||||
"template_receipts": "Quittungen automatisch archivieren",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "Also move messages marked as spam",
|
||||
"attachment_type_placeholder": "e.g. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Value (multiple separated by commas)",
|
||||
"regex_placeholder": "Regular expression, e.g. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Attachment",
|
||||
"from": "From",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "starts with",
|
||||
"ends_with": "ends with",
|
||||
"matches": "matches pattern",
|
||||
"regex": "matches regex",
|
||||
"greater_than": "is greater than",
|
||||
"less_than": "is less than",
|
||||
"address_is": "is the address",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "Enter an address to forward to",
|
||||
"validation_action_reject": "Enter the rejection message",
|
||||
"validation_header_name": "Header names may only contain the letters A-Z, digits, hyphens and underscores",
|
||||
"validation_regex": "This regular expression is not valid",
|
||||
"templates_section": "Start from template",
|
||||
"template_newsletters": "Move newsletters to folder",
|
||||
"template_receipts": "Auto-archive receipts",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Mover también los mensajes marcados como spam",
|
||||
"attachment_type_placeholder": "p. ej. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valor (varios separados por comas)",
|
||||
"regex_placeholder": "Expresión regular, p. ej. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Adjunto",
|
||||
"from": "De",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "comienza con",
|
||||
"ends_with": "termina con",
|
||||
"matches": "coincide con el patrón",
|
||||
"regex": "coincide con la expresión regular",
|
||||
"greater_than": "es mayor que",
|
||||
"less_than": "es menor que",
|
||||
"address_is": "es la dirección",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Introduce una dirección a la que reenviar",
|
||||
"validation_action_reject": "Introduce el mensaje de rechazo",
|
||||
"validation_header_name": "Los nombres de encabezado solo pueden contener las letras A-Z, dígitos, guiones y guiones bajos",
|
||||
"validation_regex": "Esta expresión regular no es válida",
|
||||
"templates_section": "Usar una plantilla",
|
||||
"template_newsletters": "Mover newsletters a carpeta",
|
||||
"template_receipts": "Archivar recibos automáticamente",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "Liiguta ka rämpspostiks märgitud kirjad",
|
||||
"attachment_type_placeholder": "nt pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Väärtus (mitu, komadega eraldatud)",
|
||||
"regex_placeholder": "Regulaaravaldis, nt ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Manus",
|
||||
"from": "Saatja",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "algab",
|
||||
"ends_with": "lõpeb",
|
||||
"matches": "vastab mustrile",
|
||||
"regex": "vastab regulaaravaldisele",
|
||||
"greater_than": "on suurem kui",
|
||||
"less_than": "on väiksem kui",
|
||||
"address_is": "on aadress",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "Sisesta aadress, kuhu edasi saata",
|
||||
"validation_action_reject": "Sisesta tagasilükkamise sõnum",
|
||||
"validation_header_name": "Päise nimes võivad olla ainult tähed A-Z, numbrid, sidekriipsud ja allkriipsud",
|
||||
"validation_regex": "See regulaaravaldis ei ole kehtiv",
|
||||
"templates_section": "Alusta mallist",
|
||||
"template_newsletters": "Liiguta uudiskirjad kausta",
|
||||
"template_receipts": "Arhiveeri kviitungid automaatselt",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "پیامهای علامتخورده بهعنوان هرزنامه هم منتقل شوند",
|
||||
"attachment_type_placeholder": "مثال: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "مقدار (چندتا با کاما جدا شوند)",
|
||||
"regex_placeholder": "عبارت باقاعده، مثلاً ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "پیوست",
|
||||
"from": "از",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "شروع میشود با",
|
||||
"ends_with": "پایان مییابد با",
|
||||
"matches": "مطابقت با الگو",
|
||||
"regex": "با عبارت باقاعده مطابقت دارد",
|
||||
"greater_than": "بزرگتر از",
|
||||
"less_than": "کوچکتر از",
|
||||
"address_is": "نشانیاش این است",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "نشانیای برای هدایت وارد کنید",
|
||||
"validation_action_reject": "پیام رد را وارد کنید",
|
||||
"validation_header_name": "نام هدر فقط میتواند شامل حروف A-Z، ارقام، خط تیره و زیرخط باشد",
|
||||
"validation_regex": "این عبارت باقاعده معتبر نیست",
|
||||
"templates_section": "شروع از قالب",
|
||||
"template_newsletters": "انتقال خبرنامهها به پوشه",
|
||||
"template_receipts": "بایگانی خودکار رسیدها",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Déplacer aussi les messages marqués comme spam",
|
||||
"attachment_type_placeholder": "p. ex. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valeur (plusieurs séparées par des virgules)",
|
||||
"regex_placeholder": "Expression régulière, p. ex. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Pièce jointe",
|
||||
"from": "De",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "commence par",
|
||||
"ends_with": "se termine par",
|
||||
"matches": "correspond au motif",
|
||||
"regex": "correspond à l'expression régulière",
|
||||
"greater_than": "est supérieur à",
|
||||
"less_than": "est inférieur à",
|
||||
"address_is": "est l'adresse",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Saisissez une adresse de transfert",
|
||||
"validation_action_reject": "Saisissez le message de refus",
|
||||
"validation_header_name": "Les noms d'en-tête ne peuvent contenir que les lettres A-Z, des chiffres, des traits d'union et des tirets bas",
|
||||
"validation_regex": "Cette expression régulière n'est pas valide",
|
||||
"templates_section": "Partir d'un modèle",
|
||||
"template_newsletters": "Déplacer les newsletters dans un dossier",
|
||||
"template_receipts": "Archiver les reçus automatiquement",
|
||||
|
||||
@@ -2614,6 +2614,7 @@
|
||||
"starts_with": "מתחיל עם",
|
||||
"ends_with": "מסתיים ב",
|
||||
"matches": "תואם דפוס",
|
||||
"regex": "תואם לביטוי רגולרי",
|
||||
"greater_than": "גדול מ",
|
||||
"less_than": "הוא פחות מ",
|
||||
"has_any": "קיים",
|
||||
@@ -2678,6 +2679,7 @@
|
||||
"validation_action_forward": "הזן כתובת להעברה",
|
||||
"validation_action_reject": "הזן את הודעת הדחייה",
|
||||
"validation_header_name": "שמות כותרות יכולים להכיל רק את האותיות A-Z, ספרות, מקפים וקווים תחתונים",
|
||||
"validation_regex": "הביטוי הרגולרי אינו תקין",
|
||||
"templates_section": "התחל מהתבנית",
|
||||
"template_newsletters": "העבר ניוזלטרים לתיקיה",
|
||||
"template_receipts": "העבר קבלות לארכיון אוטומטי",
|
||||
@@ -2703,6 +2705,7 @@
|
||||
},
|
||||
"attachment_type_placeholder": "למשל pdf, doc, jpg",
|
||||
"value_placeholder_multi": "ערך (מרובים מופרדים בפסיקים)",
|
||||
"regex_placeholder": "ביטוי רגולרי, לדוגמה ^Cron|mailman",
|
||||
"origin_external": "חיצוני",
|
||||
"managed_by_tooltip": "מנוהל על ידי {source}. ערוך אותו ביישום זה, או השתמש בעורך Sieve הגולמי.",
|
||||
"quick_rule_names": {
|
||||
|
||||
@@ -2678,6 +2678,7 @@
|
||||
"include_spam": "A levélszemétnek jelölt üzenetek áthelyezése is",
|
||||
"attachment_type_placeholder": "pl. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Érték (több érték vesszővel elválasztva)",
|
||||
"regex_placeholder": "Reguláris kifejezés, pl. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Csatolmány",
|
||||
"from": "Feladó",
|
||||
@@ -2699,6 +2700,7 @@
|
||||
"starts_with": "kezdődik",
|
||||
"ends_with": "végződik",
|
||||
"matches": "illeszkedik a mintára",
|
||||
"regex": "illeszkedik a reguláris kifejezésre",
|
||||
"greater_than": "nagyobb, mint",
|
||||
"less_than": "kisebb, mint",
|
||||
"address_is": "címe pontosan",
|
||||
@@ -2761,6 +2763,7 @@
|
||||
"validation_action_forward": "Adja meg a továbbítási címet",
|
||||
"validation_action_reject": "Adja meg az elutasító üzenetet",
|
||||
"validation_header_name": "A fejlécnevek csak az A-Z betűket, számjegyeket, kötőjeleket és aláhúzásjeleket tartalmazhatják",
|
||||
"validation_regex": "Ez a reguláris kifejezés érvénytelen",
|
||||
"templates_section": "Kezdés sablonból",
|
||||
"template_newsletters": "Hírlevelek áthelyezése mappába",
|
||||
"template_receipts": "Nyugták automatikus archiválása",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Sposta anche i messaggi contrassegnati come spam",
|
||||
"attachment_type_placeholder": "es. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valore (più valori separati da virgole)",
|
||||
"regex_placeholder": "Espressione regolare, ad es. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Allegato",
|
||||
"from": "Da",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "inizia con",
|
||||
"ends_with": "termina con",
|
||||
"matches": "corrisponde al modello",
|
||||
"regex": "corrisponde all'espressione regolare",
|
||||
"greater_than": "è maggiore di",
|
||||
"less_than": "è minore di",
|
||||
"address_is": "è l'indirizzo",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Inserisci un indirizzo a cui inoltrare",
|
||||
"validation_action_reject": "Inserisci il messaggio di rifiuto",
|
||||
"validation_header_name": "I nomi delle intestazioni possono contenere solo le lettere A-Z, cifre, trattini e trattini bassi",
|
||||
"validation_regex": "Questa espressione regolare non è valida",
|
||||
"templates_section": "Parti da un modello",
|
||||
"template_newsletters": "Sposta newsletter in cartella",
|
||||
"template_receipts": "Archivia ricevute automaticamente",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "迷惑メールと判定されたメッセージも移動する",
|
||||
"attachment_type_placeholder": "例: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "値(カンマで複数指定可)",
|
||||
"regex_placeholder": "正規表現(例: ^Cron|mailman)",
|
||||
"condition_fields": {
|
||||
"attachment": "添付ファイル",
|
||||
"from": "差出人",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "で始まる",
|
||||
"ends_with": "で終わる",
|
||||
"matches": "パターンに一致",
|
||||
"regex": "正規表現に一致",
|
||||
"greater_than": "より大きい",
|
||||
"less_than": "より小さい",
|
||||
"address_is": "のアドレスが次と一致",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "転送先のアドレスを入力してください",
|
||||
"validation_action_reject": "拒否メッセージを入力してください",
|
||||
"validation_header_name": "ヘッダー名に使用できるのは英字(A-Z)、数字、ハイフン、アンダースコアのみです",
|
||||
"validation_regex": "この正規表現は無効です",
|
||||
"templates_section": "テンプレートから作成",
|
||||
"template_newsletters": "ニュースレターをフォルダに移動",
|
||||
"template_receipts": "領収書を自動アーカイブ",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "스팸으로 표시된 메시지도 이동",
|
||||
"attachment_type_placeholder": "예: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "값 (쉼표로 여러 개 구분)",
|
||||
"regex_placeholder": "정규식 (예: ^Cron|mailman)",
|
||||
"condition_fields": {
|
||||
"attachment": "첨부 파일",
|
||||
"from": "보낸 사람",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "다음으로 시작",
|
||||
"ends_with": "다음으로 끝남",
|
||||
"matches": "패턴 일치",
|
||||
"regex": "정규식과 일치",
|
||||
"greater_than": "다음보다 큼",
|
||||
"less_than": "다음보다 작음",
|
||||
"address_is": "주소가 다음과 같음",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "전달할 주소를 입력하세요",
|
||||
"validation_action_reject": "거부 메시지를 입력하세요",
|
||||
"validation_header_name": "헤더 이름에는 영문자(A-Z), 숫자, 하이픈, 밑줄만 쓸 수 있어요",
|
||||
"validation_regex": "올바르지 않은 정규식입니다",
|
||||
"templates_section": "템플릿으로 시작하기",
|
||||
"template_newsletters": "뉴스레터를 특정 폴더로 이동",
|
||||
"template_receipts": "영수증 자동 보관",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Pārvietot arī ziņas, kas atzīmētas kā mēstules",
|
||||
"attachment_type_placeholder": "piem. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Vērtība (vairākas atdalītas ar komatu)",
|
||||
"regex_placeholder": "Regulārā izteiksme, piem., ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Pielikums",
|
||||
"from": "No",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "sākas ar",
|
||||
"ends_with": "beidzas ar",
|
||||
"matches": "atbilst paraugam",
|
||||
"regex": "atbilst regulārajai izteiksmei",
|
||||
"greater_than": "lielāks nekā",
|
||||
"less_than": "mazāks nekā",
|
||||
"address_is": "ir adrese",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Ievadiet adresi, uz kuru pārsūtīt",
|
||||
"validation_action_reject": "Ievadiet noraidīšanas ziņojumu",
|
||||
"validation_header_name": "Galvenes nosaukumā drīkst būt tikai burti A-Z, cipari, defises un pasvītras",
|
||||
"validation_regex": "Šī regulārā izteiksme nav derīga",
|
||||
"templates_section": "Sākt no veidnes",
|
||||
"template_newsletters": "Pārvietot ziņu izdevumus uz mapi",
|
||||
"template_receipts": "Automātiski arhivēt rēķinus",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "Спам гэж тэмдэглэсэн захидлыг мөн зөөх",
|
||||
"attachment_type_placeholder": "жишээ нь pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Утга (олон тоог таслалаар тусгаарласан)",
|
||||
"regex_placeholder": "Тогтмол илэрхийлэл, ж.нь ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Хавсралт",
|
||||
"from": "-аас",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "",
|
||||
"ends_with": "",
|
||||
"matches": "загварт тохирно",
|
||||
"regex": "тогтмол илэрхийлэлтэй таарч байна",
|
||||
"greater_than": " нь",
|
||||
"less_than": " нь",
|
||||
"address_is": "хаяг нь",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "Дамжуулах хаягаа оруулна уу",
|
||||
"validation_action_reject": "Татгалзах мессежээ оруулна уу",
|
||||
"validation_header_name": "Толгойн нэрэнд зөвхөн A-Z үсэг, тоо, зураас, доогуур зураас ашиглаж болно",
|
||||
"validation_regex": "Энэ тогтмол илэрхийлэл буруу байна",
|
||||
"templates_section": "Загвар",
|
||||
"template_newsletters": "Мэдээллийн хуудсыг хавтас руу зөөх",
|
||||
"template_receipts": "Автоматаар архивлах баримт",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "Flytt også meldinger som er merket som søppelpost",
|
||||
"attachment_type_placeholder": "f.eks. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Verdi (skill flere verdier med komma)",
|
||||
"regex_placeholder": "Regulært uttrykk, f.eks. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Vedlegg",
|
||||
"from": "Fra",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "starter med",
|
||||
"ends_with": "slutter med",
|
||||
"matches": "samsvarer med mønsteret",
|
||||
"regex": "samsvarer med regulært uttrykk",
|
||||
"greater_than": "er større enn",
|
||||
"less_than": "er mindre enn",
|
||||
"address_is": "er adressen",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "Skriv inn en adresse det skal videresendes til",
|
||||
"validation_action_reject": "Skriv inn avvisningsmeldingen",
|
||||
"validation_header_name": "Navn på meldingshoder kan bare inneholde bokstavene A-Z, sifre, bindestreker og understreker",
|
||||
"validation_regex": "Dette regulære uttrykket er ugyldig",
|
||||
"templates_section": "Start med en mal",
|
||||
"template_newsletters": "Flytt nyhetsbrev til mappe",
|
||||
"template_receipts": "Arkiver kvitteringer automatisk",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Ook berichten verplaatsen die als spam zijn gemarkeerd",
|
||||
"attachment_type_placeholder": "bijv. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Waarde (meerdere met komma's gescheiden)",
|
||||
"regex_placeholder": "Reguliere expressie, bijv. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Bijlage",
|
||||
"from": "Van",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "begint met",
|
||||
"ends_with": "eindigt met",
|
||||
"matches": "komt overeen met patroon",
|
||||
"regex": "komt overeen met reguliere expressie",
|
||||
"greater_than": "is groter dan",
|
||||
"less_than": "is kleiner dan",
|
||||
"address_is": "is het adres",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Vul een adres in om naar door te sturen",
|
||||
"validation_action_reject": "Vul het afwijzingsbericht in",
|
||||
"validation_header_name": "Headernamen mogen alleen de letters A-Z, cijfers, koppeltekens en underscores bevatten",
|
||||
"validation_regex": "Deze reguliere expressie is ongeldig",
|
||||
"templates_section": "Starten vanuit sjabloon",
|
||||
"template_newsletters": "Nieuwsbrieven naar map verplaatsen",
|
||||
"template_receipts": "Bonnen automatisch archiveren",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Przenoś również wiadomości oznaczone jako spam",
|
||||
"attachment_type_placeholder": "np. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Wartość (kilka oddzielonych przecinkami)",
|
||||
"regex_placeholder": "Wyrażenie regularne, np. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Załącznik",
|
||||
"from": "Od",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "zaczyna się od",
|
||||
"ends_with": "kończy się na",
|
||||
"matches": "pasuje do wzorca",
|
||||
"regex": "pasuje do wyrażenia regularnego",
|
||||
"greater_than": "jest większe niż",
|
||||
"less_than": "jest mniejsze niż",
|
||||
"address_is": "to adres",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Wpisz adres, na który przekazywać",
|
||||
"validation_action_reject": "Wpisz wiadomość odrzucenia",
|
||||
"validation_header_name": "Nazwy nagłówków mogą zawierać tylko litery A-Z, cyfry, łączniki i podkreślenia",
|
||||
"validation_regex": "To wyrażenie regularne jest nieprawidłowe",
|
||||
"templates_section": "Rozpocznij od szablonu",
|
||||
"template_newsletters": "Przenieś newslettery do folderu",
|
||||
"template_receipts": "Automatycznie archiwizuj rachunki",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Mover também as mensagens marcadas como spam",
|
||||
"attachment_type_placeholder": "ex.: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valor (vários separados por vírgulas)",
|
||||
"regex_placeholder": "Expressão regular, ex.: ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Anexo",
|
||||
"from": "De",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "começa com",
|
||||
"ends_with": "termina com",
|
||||
"matches": "corresponde ao padrão",
|
||||
"regex": "corresponde à expressão regular",
|
||||
"greater_than": "é maior que",
|
||||
"less_than": "é menor que",
|
||||
"address_is": "é o endereço",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Digite um endereço para encaminhar",
|
||||
"validation_action_reject": "Digite a mensagem de rejeição",
|
||||
"validation_header_name": "Os nomes de cabeçalho só podem conter as letras A-Z, algarismos, hífenes e sublinhados",
|
||||
"validation_regex": "Esta expressão regular não é válida",
|
||||
"templates_section": "Começar com modelo",
|
||||
"template_newsletters": "Mover newsletters para pasta",
|
||||
"template_receipts": "Arquivar recibos automaticamente",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "Mută și mesajele marcate ca spam",
|
||||
"attachment_type_placeholder": "de ex. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Valoare (mai multe valori separate prin virgule)",
|
||||
"regex_placeholder": "Expresie regulată, de ex. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Atașament",
|
||||
"from": "De la",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "începe cu",
|
||||
"ends_with": "se termină cu",
|
||||
"matches": "se potrivește cu modelul",
|
||||
"regex": "se potrivește cu expresia regulată",
|
||||
"greater_than": "este mai mare decât",
|
||||
"less_than": "este mai mic decât",
|
||||
"address_is": "este adresa",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "Introduceți o adresă către care să se redirecționeze",
|
||||
"validation_action_reject": "Introduceți mesajul de respingere",
|
||||
"validation_header_name": "Numele antetelor pot conține doar literele A-Z, cifre, cratime și liniuțe de subliniere",
|
||||
"validation_regex": "Această expresie regulată nu este validă",
|
||||
"templates_section": "Începeți de la șablon",
|
||||
"template_newsletters": "Mutați buletinele informative în dosar",
|
||||
"template_receipts": "Arhivarea automată a confirmărilor de primire",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Перемещать также письма, помеченные как спам",
|
||||
"attachment_type_placeholder": "напр. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Значение (несколько через запятую)",
|
||||
"regex_placeholder": "Регулярное выражение, например ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Вложение",
|
||||
"from": "От",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "начинается с",
|
||||
"ends_with": "заканчивается на",
|
||||
"matches": "соответствует шаблону",
|
||||
"regex": "соответствует регулярному выражению",
|
||||
"greater_than": "больше чем",
|
||||
"less_than": "меньше чем",
|
||||
"address_is": "— это адрес",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Введите адрес для пересылки",
|
||||
"validation_action_reject": "Введите сообщение об отклонении",
|
||||
"validation_header_name": "Имя заголовка может содержать только буквы A-Z, цифры, дефисы и подчёркивания",
|
||||
"validation_regex": "Это регулярное выражение недопустимо",
|
||||
"templates_section": "Начать с шаблона",
|
||||
"template_newsletters": "Перемещать рассылки в папку",
|
||||
"template_receipts": "Автоархивировать квитанции",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "Presúvať aj správy označené ako spam",
|
||||
"attachment_type_placeholder": "napr. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Hodnota (viac oddelených čiarkami)",
|
||||
"regex_placeholder": "Regulárny výraz, napr. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Príloha",
|
||||
"from": "Od",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "začína na",
|
||||
"ends_with": "končí na",
|
||||
"matches": "zodpovedá vzoru",
|
||||
"regex": "zodpovedá regulárnemu výrazu",
|
||||
"greater_than": "je väčšia ako",
|
||||
"less_than": "je menšia ako",
|
||||
"address_is": "je adresa",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "Zadajte adresu, na ktorú sa má preposielať",
|
||||
"validation_action_reject": "Zadajte správu o odmietnutí",
|
||||
"validation_header_name": "Názvy hlavičiek môžu obsahovať iba písmená A-Z, číslice, spojovníky a podčiarkovníky",
|
||||
"validation_regex": "Tento regulárny výraz nie je platný",
|
||||
"templates_section": "Začať zo šablóny",
|
||||
"template_newsletters": "Presunúť newslettery do priečinka",
|
||||
"template_receipts": "Automaticky archivovať účtenky",
|
||||
|
||||
@@ -5123,6 +5123,7 @@
|
||||
"include_spam": "ย้ายข้อความที่ทำเครื่องหมายว่าเป็นสแปมด้วย",
|
||||
"attachment_type_placeholder": "เช่น pdf, doc, jpg",
|
||||
"value_placeholder_multi": "ค่า (หากมีหลายค่าให้คั่นด้วยเครื่องหมายจุลภาค)",
|
||||
"regex_placeholder": "นิพจน์ทั่วไป เช่น ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "ไฟล์แนบ",
|
||||
"from": "ผู้ส่ง (From)",
|
||||
@@ -5144,6 +5145,7 @@
|
||||
"starts_with": "ขึ้นต้นด้วย",
|
||||
"ends_with": "ลงท้ายด้วย",
|
||||
"matches": "ตรงตามรูปแบบ",
|
||||
"regex": "ตรงกับนิพจน์ทั่วไป",
|
||||
"greater_than": "มากกว่า",
|
||||
"less_than": "น้อยกว่า",
|
||||
"address_is": "เป็นที่อยู่อีเมล",
|
||||
@@ -5206,6 +5208,7 @@
|
||||
"validation_action_forward": "ป้อนที่อยู่ที่จะส่งต่อไป",
|
||||
"validation_action_reject": "ป้อนข้อความปฏิเสธ",
|
||||
"validation_header_name": "ชื่อส่วนหัวต้องประกอบด้วยตัวอักษร A-Z, ตัวเลข, เครื่องหมายยัติภังค์ และขีดล่างเท่านั้น",
|
||||
"validation_regex": "นิพจน์ทั่วไปนี้ไม่ถูกต้อง",
|
||||
"templates_section": "เริ่มต้นจากเทมเพลต",
|
||||
"template_newsletters": "ย้ายจดหมายข่าวไปยังโฟลเดอร์",
|
||||
"template_receipts": "เก็บใบเสร็จถาวรอัตโนมัติ",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "İstenmeyen olarak işaretlenen iletileri de taşı",
|
||||
"attachment_type_placeholder": "örn. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Değer (birden fazla virgülle ayrılır)",
|
||||
"regex_placeholder": "Normal ifade, ör. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Ek",
|
||||
"from": "Kimden",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "ile başlar",
|
||||
"ends_with": "ile biter",
|
||||
"matches": "desenle eşleşir",
|
||||
"regex": "normal ifadeyle eşleşir",
|
||||
"greater_than": "büyüktür",
|
||||
"less_than": "küçüktür",
|
||||
"address_is": "adresi şudur",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "İletilecek bir adres girin",
|
||||
"validation_action_reject": "Ret mesajını girin",
|
||||
"validation_header_name": "Başlık adları yalnızca A-Z harflerini, rakamları, kısa çizgileri ve alt çizgileri içerebilir",
|
||||
"validation_regex": "Bu normal ifade geçerli değil",
|
||||
"templates_section": "Şablondan başla",
|
||||
"template_newsletters": "Bültenleri klasöre taşı",
|
||||
"template_receipts": "Makbuzları otomatik arşivle",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "Переміщувати також листи, позначені як спам",
|
||||
"attachment_type_placeholder": "напр. pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Значення (декілька через кому)",
|
||||
"regex_placeholder": "Регулярний вираз, напр. ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Вкладення",
|
||||
"from": "Від",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "починається з",
|
||||
"ends_with": "закінчується на",
|
||||
"matches": "відповідає шаблону",
|
||||
"regex": "відповідає регулярному виразу",
|
||||
"greater_than": "більше ніж",
|
||||
"less_than": "менше ніж",
|
||||
"address_is": "— це адреса",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "Введіть адресу для пересилання",
|
||||
"validation_action_reject": "Введіть повідомлення про відхилення",
|
||||
"validation_header_name": "Назва заголовка може містити лише літери A-Z, цифри, дефіси та підкреслення",
|
||||
"validation_regex": "Цей регулярний вираз недійсний",
|
||||
"templates_section": "Почніть із шаблону",
|
||||
"template_newsletters": "Перемістити інформаційні бюлетені в папку",
|
||||
"template_receipts": "Автоматичне архівування квитанцій",
|
||||
|
||||
@@ -2694,6 +2694,7 @@
|
||||
"include_spam": "Đồng thời di chuyển các thư bị đánh dấu là thư rác",
|
||||
"attachment_type_placeholder": "Ví dụ: pdf, doc, jpg",
|
||||
"value_placeholder_multi": "Giá trị (phân tách nhiều giá trị bằng dấu phẩy)",
|
||||
"regex_placeholder": "Biểu thức chính quy, ví dụ ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "Tệp đính kèm",
|
||||
"from": "Từ",
|
||||
@@ -2715,6 +2716,7 @@
|
||||
"starts_with": "Bắt đầu bằng",
|
||||
"ends_with": "Kết thúc bằng",
|
||||
"matches": "Khớp mẫu",
|
||||
"regex": "khớp biểu thức chính quy",
|
||||
"greater_than": "Lớn hơn",
|
||||
"less_than": "Ít hơn",
|
||||
"address_is": "Là địa chỉ",
|
||||
@@ -2777,6 +2779,7 @@
|
||||
"validation_action_forward": "Nhập địa chỉ để chuyển tiếp đến",
|
||||
"validation_action_reject": "Nhập thư từ chối",
|
||||
"validation_header_name": "Tên tiêu đề chỉ có thể chứa các chữ cái A-Z, chữ số, dấu gạch ngang và dấu gạch dưới",
|
||||
"validation_regex": "Biểu thức chính quy này không hợp lệ",
|
||||
"templates_section": "Bắt đầu từ mẫu",
|
||||
"template_newsletters": "Di chuyển bản tin vào thư mục",
|
||||
"template_receipts": "Tự động lưu trữ biên lai",
|
||||
|
||||
@@ -2682,6 +2682,7 @@
|
||||
"include_spam": "同時移動被標記為垃圾郵件的郵件",
|
||||
"attachment_type_placeholder": "例如:pdf、doc、jpg",
|
||||
"value_placeholder_multi": "值(多個用逗號分隔)",
|
||||
"regex_placeholder": "正規表示式,例如 ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "附件",
|
||||
"from": "寄件者",
|
||||
@@ -2703,6 +2704,7 @@
|
||||
"starts_with": "以…開頭",
|
||||
"ends_with": "以…結尾",
|
||||
"matches": "符合模式",
|
||||
"regex": "符合正規表示式",
|
||||
"greater_than": "大於",
|
||||
"less_than": "小於",
|
||||
"address_is": "地址為",
|
||||
@@ -2765,6 +2767,7 @@
|
||||
"validation_action_forward": "請輸入要轉寄到的地址",
|
||||
"validation_action_reject": "請輸入退信訊息",
|
||||
"validation_header_name": "標頭名稱只能包含字母 A-Z、數字、連字號和底線",
|
||||
"validation_regex": "此正規表示式無效",
|
||||
"templates_section": "從範本開始",
|
||||
"template_newsletters": "將訂閱郵件移至信件匣",
|
||||
"template_receipts": "自動封存收據",
|
||||
|
||||
@@ -2675,6 +2675,7 @@
|
||||
"include_spam": "同时移动被标记为垃圾邮件的邮件",
|
||||
"attachment_type_placeholder": "例如:pdf、doc、jpg",
|
||||
"value_placeholder_multi": "值(多个用逗号分隔)",
|
||||
"regex_placeholder": "正则表达式,例如 ^Cron|mailman",
|
||||
"condition_fields": {
|
||||
"attachment": "附件",
|
||||
"from": "发件人",
|
||||
@@ -2696,6 +2697,7 @@
|
||||
"starts_with": "以…开头",
|
||||
"ends_with": "以…结尾",
|
||||
"matches": "匹配模式",
|
||||
"regex": "匹配正则表达式",
|
||||
"greater_than": "大于",
|
||||
"less_than": "小于",
|
||||
"address_is": "地址为",
|
||||
@@ -2758,6 +2760,7 @@
|
||||
"validation_action_forward": "请输入要转发到的地址",
|
||||
"validation_action_reject": "请输入拒收消息",
|
||||
"validation_header_name": "标头名称只能包含字母 A-Z、数字、连字符和下划线",
|
||||
"validation_regex": "此正则表达式无效",
|
||||
"templates_section": "从模板开始",
|
||||
"template_newsletters": "将订阅邮件移至文件夹",
|
||||
"template_receipts": "自动归档收据",
|
||||
|
||||
@@ -437,7 +437,7 @@ interface EmailStore {
|
||||
/** `ownerAccountId`: the JMAP account a link names for the message (#1351). */
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; localAccountId?: string; draftAccountId?: string }) => Promise<SendEmailResult>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; localAccountId?: string; draftAccountId?: string }) => Promise<SendEmailResult>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
||||
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise<boolean>;
|
||||
|
||||
Reference in New Issue
Block a user