Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e1c8c5f
feat(email): inbox notification email channel (phase 1)
r404r Jun 13, 2026
7dad651
Merge upstream/main (2026-06-15 batch) into feat/email-notifications
r404r Jun 15, 2026
3eda7f0
Merge upstream/main (2026-09-25 batch) into feat/email-notifications
r404r Sep 25, 2026
2f87aea
fix(notifications): include email_notifications in preference patches
r404r Sep 25, 2026
1a4039b
fix(email): fail closed when notification preferences cannot be read
r404r Sep 25, 2026
bbfa698
refactor(settings): render the email toggle with the shared settings …
r404r Sep 25, 2026
7a664f6
i18n(fr): translate the email notification setting
r404r Sep 25, 2026
abde915
fix(email): link notification emails through the configured app URL
r404r Sep 25, 2026
41d7851
fix(email): bound notification fan-out and retry rate-limited sends
r404r Sep 25, 2026
bf8a3c9
fix(email): queue notifications and pace sends below the provider limit
r404r Sep 25, 2026
5bd23bb
fix(email): bound each notification delivery by its timeout
r404r Sep 25, 2026
4a20bfc
fix(selfhost): pass EMAIL_NOTIFICATION_SEND_INTERVAL to the backend
r404r Sep 25, 2026
017cf33
test(email): drop trailing blank line in notifier tests
r404r Sep 25, 2026
d328aa1
fix(email): only email current workspace members
r404r Sep 25, 2026
499b864
Merge upstream/main into feat/email-notifications
r404r Oct 28, 2026
3e41422
fix(email): name every inbox type in notification subjects
r404r Oct 22, 2026
3d23062
fix(settings): make the email notification row searchable
r404r Oct 28, 2026
42dd068
Merge upstream/main into feat/email-notifications
r404r Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ SMTP_FROM_EMAIL=
SMTP_TLS_INSECURE=false
SMTP_TLS=
SMTP_EHLO_NAME=
#
# Inbox notification emails are sent when either option above is configured.
# EMAIL_NOTIFICATION_SEND_INTERVAL is the minimum spacing between notification
# sends per backend replica (Go duration, default 1s). It keeps notifications
# below Resend's default 2 requests/second so login codes and invitations on the
# same key are not rate limited; with N replicas sharing a key, use about N x 1s.
EMAIL_NOTIFICATION_SEND_INTERVAL=

# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
Expand Down
1 change: 1 addition & 0 deletions docker-compose.selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ services:
SMTP_TLS: ${SMTP_TLS:-}
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
SMTP_EHLO_NAME: ${SMTP_EHLO_NAME:-}
EMAIL_NOTIFICATION_SEND_INTERVAL: ${EMAIL_NOTIFICATION_SEND_INTERVAL:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,7 @@ export interface AppConfigResponse {
daemon_app_url?: string;
daemon_setup_target?: "cloud" | "self_host" | "unknown";
workspace_creation_disabled?: boolean;
email_configured?: boolean;
/** Whether this deployment offers the self-hosted Git provider integration
* (self-host only; off on the managed cloud). Absent/false hides the whole
* Settings → Integrations "Git providers" section. */
Expand Down Expand Up @@ -1202,6 +1203,7 @@ export const AppConfigSchema = z.object({
daemon_app_url: OptionalStringSchema,
daemon_setup_target: z.enum(["cloud", "self_host", "unknown"]).optional().catch("unknown"),
workspace_creation_disabled: BooleanWithDefaultSchema(false).optional(),
email_configured: BooleanWithDefaultSchema(false).optional(),
vcs_integration_available: BooleanWithDefaultSchema(false).optional(),
feature_flags: FeatureFlagsSchema,
local_worktree_supported: BooleanWithDefaultSchema(false),
Expand Down
16 changes: 15 additions & 1 deletion packages/core/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ interface ConfigState {
// must be hidden. Defaults to false so unknown / older servers behave like
// the managed-cloud case.
workspaceCreationDisabled: boolean;
// Email gate: when true, the server has SMTP/Resend configured and can
// deliver email notifications. Defaults to false; older servers that omit
// the field are treated as "email not configured".
emailConfigured: boolean;
// Self-host-only gate for the Git provider integration (Forgejo / Gitea /
// GitLab). When false the whole Settings → Integrations "Git providers"
// section is hidden. Defaults to false so unknown / older servers and the
Expand Down Expand Up @@ -53,6 +57,7 @@ interface ConfigState {
allowSignup: boolean;
googleClientId?: string;
workspaceCreationDisabled?: boolean;
emailConfigured?: boolean;
vcsIntegrationAvailable?: boolean;
}) => void;
setDaemonConfig: (config: {
Expand Down Expand Up @@ -80,6 +85,7 @@ export const configStore = createStore<ConfigState>((set) => ({
daemonConfigStatus: "loading",
daemonSetupTarget: "unknown",
workspaceCreationDisabled: false,
emailConfigured: false,
vcsIntegrationAvailable: false,
featureFlags: {},
serverVersion: "",
Expand All @@ -93,8 +99,16 @@ export const configStore = createStore<ConfigState>((set) => ({
allowSignup,
googleClientId = "",
workspaceCreationDisabled = false,
emailConfigured = false,
vcsIntegrationAvailable = false,
}) => set({ allowSignup, googleClientId, workspaceCreationDisabled, vcsIntegrationAvailable }),
}) =>
set({
allowSignup,
googleClientId,
workspaceCreationDisabled,
emailConfigured,
vcsIntegrationAvailable,
}),
setDaemonConfig: ({
daemonServerUrl = "",
daemonAppUrl = "",
Expand Down
9 changes: 9 additions & 0 deletions packages/core/notification-preferences/patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,13 @@ describe("notification preference patches", () => {
),
).toEqual({});
});

it("patches the email delivery toggle like any other group", () => {
const muted = deriveNotificationPreferencePatch({}, { email_notifications: "muted" });
expect(muted).toEqual({ email_notifications: "muted" });
expect(applyNotificationPreferencePatch({}, muted)).toEqual({ email_notifications: "muted" });
expect(
deriveNotificationPreferencePatch({ email_notifications: "muted" }, {}),
).toEqual({ email_notifications: "all" });
});
});
1 change: 1 addition & 0 deletions packages/core/notification-preferences/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const NOTIFICATION_GROUP_KEYS: readonly NotificationGroupKey[] = [
"updates",
"agent_activity",
"system_notifications",
"email_notifications",
];

function preferenceValue(
Expand Down
3 changes: 3 additions & 0 deletions packages/core/platform/auth-initializer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export function AuthInitializer({
// Old servers omit this field — treat that as "creation allowed"
// (the managed-cloud default) rather than blocking the UI.
workspaceCreationDisabled: cfg.workspace_creation_disabled === true,
// Old servers omit this field — treat that as "email not configured"
// so the channel toggle shows "Unavailable" instead of a broken toggle.
emailConfigured: cfg.email_configured === true,
// Absent/false on the managed cloud and older servers → section hidden.
vcsIntegrationAvailable: cfg.vcs_integration_available === true,
});
Expand Down
3 changes: 2 additions & 1 deletion packages/core/types/notification-preference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export type NotificationGroupKey =
| "mentions"
| "updates"
| "agent_activity"
| "system_notifications";
| "system_notifications"
| "email_notifications";

export type NotificationGroupValue = "all" | "muted";

Expand Down
6 changes: 6 additions & 0 deletions packages/views/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,12 @@
"granted": "This browser can show notification banners.",
"denied": "Blocked by your browser. Enable notifications for this site in your browser settings.",
"enabled_badge": "Enabled"
},
"email": {
"label": "Email notifications",
"hint_enabled": "You'll get an email when there's new inbox activity.",
"hint_unavailable": "Email isn't configured on this server. Set SMTP_HOST or RESEND_API_KEY to enable.",
"unavailable_badge": "Unavailable"
}
},
"tokens": {
Expand Down
6 changes: 6 additions & 0 deletions packages/views/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,12 @@
"granted": "Este navegador puede mostrar avisos de notificación.",
"denied": "Bloqueado por tu navegador. Activa las notificaciones para este sitio en la configuración del navegador.",
"enabled_badge": "Activado"
},
"email": {
"label": "Notificaciones por correo",
"hint_enabled": "Recibirás un correo cuando haya actividad nueva en la bandeja.",
"hint_unavailable": "El correo no está configurado en este servidor. Define SMTP_HOST o RESEND_API_KEY para activarlo.",
"unavailable_badge": "No disponible"
}
},
"tokens": {
Expand Down
6 changes: 6 additions & 0 deletions packages/views/locales/fr/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,12 @@
"granted": "Ce navigateur peut afficher des bannières de notification.",
"denied": "Bloqué par votre navigateur. Activez les notifications pour ce site dans ses réglages.",
"enabled_badge": "Activé"
},
"email": {
"label": "Notifications par e-mail",
"hint_enabled": "Vous recevrez un e-mail en cas de nouvelle activité dans votre boîte de réception.",
"hint_unavailable": "L'e-mail n'est pas configuré sur ce serveur. Définissez SMTP_HOST ou RESEND_API_KEY pour l'activer.",
"unavailable_badge": "Indisponible"
}
},
"tokens": {
Expand Down
6 changes: 6 additions & 0 deletions packages/views/locales/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,12 @@
"granted": "このブラウザは通知バナーを表示できます。",
"denied": "ブラウザによってブロックされています。ブラウザの設定でこのサイトの通知を許可してください。",
"enabled_badge": "有効"
},
"email": {
"label": "メール通知",
"hint_enabled": "新しい受信箱の通知をメールで受け取ります。",
"hint_unavailable": "このサーバーではメール送信が未設定です。SMTP_HOST または RESEND_API_KEY を設定してください。",
"unavailable_badge": "利用不可"
}
},
"tokens": {
Expand Down
6 changes: 6 additions & 0 deletions packages/views/locales/ko/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,12 @@
"granted": "이 브라우저에서 알림 배너를 표시할 수 있습니다.",
"denied": "브라우저에서 차단되었습니다. 브라우저 설정에서 이 사이트의 알림을 허용하세요.",
"enabled_badge": "사용 중"
},
"email": {
"label": "이메일 알림",
"hint_enabled": "받은편지함에 새 알림이 도착하면 이메일로도 받습니다.",
"hint_unavailable": "이 서버에는 이메일 전송이 구성되지 않았습니다. SMTP_HOST 또는 RESEND_API_KEY를 설정하세요.",
"unavailable_badge": "사용 불가"
}
},
"tokens": {
Expand Down
6 changes: 6 additions & 0 deletions packages/views/locales/zh-Hans/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,12 @@
"granted": "当前浏览器可以弹出通知横幅。",
"denied": "已被浏览器拦截。请在浏览器设置中为本站点开启通知权限。",
"enabled_badge": "已启用"
},
"email": {
"label": "邮件通知",
"hint_enabled": "收件箱有新消息时,会同时给你的邮箱发一封提醒。",
"hint_unavailable": "服务端尚未配置邮件发送。请联系管理员设置 SMTP_HOST 或 RESEND_API_KEY。",
"unavailable_badge": "不可用"
}
},
"tokens": {
Expand Down
6 changes: 6 additions & 0 deletions packages/views/locales/zh-Hant/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,12 @@
"granted": "當前瀏覽器可以彈出通知橫幅。",
"denied": "已被瀏覽器攔截。請在瀏覽器設定中為本站點開啟通知權限。",
"enabled_badge": "已啟用"
},
"email": {
"label": "郵件通知",
"hint_enabled": "收件箱有新訊息時,會同時寄一封提醒到你的信箱。",
"hint_unavailable": "伺服器尚未設定郵件傳送。請聯絡管理員設定 SMTP_HOST 或 RESEND_API_KEY。",
"unavailable_badge": "不可用"
}
},
"tokens": {
Expand Down
167 changes: 167 additions & 0 deletions packages/views/settings/components/email-notification-setting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { ReactNode } from "react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@multica/core/i18n/react";
import { configStore } from "@multica/core/config";
import enCommon from "../../locales/en/common.json";
import enSettings from "../../locales/en/settings.json";

// ---------------------------------------------------------------------------
// Preference data controlled per test
// ---------------------------------------------------------------------------
const prefsRef = vi.hoisted(() => ({
current: {} as Record<string, string>,
}));

const mockMutate = vi.hoisted(() => vi.fn());

vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { workspace_id: "ws-1", preferences: prefsRef.current },
}),
useMutation: (_opts: unknown) => ({
mutate: (
prefs: Record<string, string>,
callbacks?: { onError?: (e: unknown) => void },
) => {
mockMutate(prefs, callbacks);
},
isPending: false,
}),
useQueryClient: () => ({
cancelQueries: vi.fn(),
getQueryData: vi.fn(),
setQueryData: vi.fn(),
invalidateQueries: vi.fn(),
}),
queryOptions: <T,>(opts: T) => opts,
}));

vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));

vi.mock("@multica/core/notification-preferences/queries", () => ({
notificationPreferenceOptions: (wsId: string) => ({
queryKey: ["notification-preferences", wsId],
queryFn: vi.fn(),
}),
notificationPreferenceKeys: {
all: (wsId: string) => ["notification-preferences", wsId],
},
}));

vi.mock("@multica/core/notification-preferences/mutations", () => ({
useUpdateNotificationPreferences: () => ({
mutate: (
prefs: Record<string, string>,
callbacks?: { onError?: (e: unknown) => void },
) => {
mockMutate(prefs, callbacks);
},
isPending: false,
}),
}));

vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));

// ---------------------------------------------------------------------------
// Import component AFTER mocks are hoisted
// ---------------------------------------------------------------------------
import { EmailNotificationSetting } from "./email-notification-setting";

// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
const TEST_RESOURCES = { en: { common: enCommon, settings: enSettings } };

function Wrapper({ children }: { children: ReactNode }) {
return (
<I18nProvider locale="en" resources={TEST_RESOURCES}>
{children}
</I18nProvider>
);
}

function resetState() {
vi.clearAllMocks();
prefsRef.current = {};
configStore.setState({ emailConfigured: false });
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("EmailNotificationSetting", () => {
beforeEach(resetState);

it("shows Unavailable badge and no toggle when emailConfigured is false", () => {
configStore.setState({ emailConfigured: false });
render(<EmailNotificationSetting />, { wrapper: Wrapper });

expect(screen.getByText("Email notifications")).toBeTruthy();
expect(
screen.getByText(
"Email isn't configured on this server. Set SMTP_HOST or RESEND_API_KEY to enable.",
),
).toBeTruthy();
expect(screen.getByText("Unavailable")).toBeTruthy();
expect(screen.queryByRole("switch")).toBeNull();
});

it("renders toggle checked when emailConfigured is true and email_notifications is not muted", () => {
configStore.setState({ emailConfigured: true });
prefsRef.current = {}; // no key → default enabled
render(<EmailNotificationSetting />, { wrapper: Wrapper });

expect(
screen.getByText("You'll get an email when there's new inbox activity."),
).toBeTruthy();
const toggle = screen.getByRole("switch");
expect(toggle).toBeTruthy();
expect((toggle as HTMLInputElement).getAttribute("aria-checked") ?? (toggle as HTMLInputElement).checked).toBeTruthy();
});

it("renders toggle unchecked when emailConfigured is true and email_notifications is muted", () => {
configStore.setState({ emailConfigured: true });
prefsRef.current = { email_notifications: "muted" };
render(<EmailNotificationSetting />, { wrapper: Wrapper });

const toggle = screen.getByRole("switch");
expect(toggle).toBeTruthy();
// aria-checked should be "false" when preference is muted
const ariaChecked = toggle.getAttribute("aria-checked");
expect(ariaChecked).toBe("false");
});

it("calls mutate with email_notifications deleted when toggled on", async () => {
configStore.setState({ emailConfigured: true });
prefsRef.current = { email_notifications: "muted" };
const user = userEvent.setup();
render(<EmailNotificationSetting />, { wrapper: Wrapper });

const toggle = screen.getByRole("switch");
await user.click(toggle);

expect(mockMutate).toHaveBeenCalledTimes(1);
const [prefs] = mockMutate.mock.calls[0] as [Record<string, string>];
expect("email_notifications" in prefs).toBe(false);
});

it("calls mutate with email_notifications=muted when toggled off", async () => {
configStore.setState({ emailConfigured: true });
prefsRef.current = {}; // currently enabled
const user = userEvent.setup();
render(<EmailNotificationSetting />, { wrapper: Wrapper });

const toggle = screen.getByRole("switch");
await user.click(toggle);

expect(mockMutate).toHaveBeenCalledTimes(1);
const [prefs] = mockMutate.mock.calls[0] as [Record<string, string>];
expect(prefs.email_notifications).toBe("muted");
});
});
Loading
Loading