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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ INTERNAL_TOKEN=change-me-32-byte-random-hex
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=

# ─── MiniMax email assistance (optional) ─────────────────
# MINIMAX_API_KEY=
# MINIMAX_MODEL=MiniMax-M3
# Global: https://api.minimax.io/v1 | CN: https://api.minimaxi.com/v1
# MINIMAX_BASE_URL=https://api.minimax.io/v1

# ══════════════════════════════════════════════════════════
# SaaS-only (CLOUD_MODE=true)
# ══════════════════════════════════════════════════════════
Expand Down
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,9 @@ REDIS_URL=redis://localhost:6379

OBLIEN_CLIENT_ID=oblien_your-client-id
OBLIEN_CLIENT_SECRET=sk_your-client-secret

# ---------- MiniMax email assistance (optional) ----------
# MINIMAX_API_KEY=
# MINIMAX_MODEL=MiniMax-M3
# Global: https://api.minimax.io/v1 | CN: https://api.minimaxi.com/v1
# MINIMAX_BASE_URL=https://api.minimax.io/v1
10 changes: 10 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
export { runtimeTarget, runtimeTargetId, cloudRuntimeTarget, cloudRuntimeTargetId };

const DEFAULT_BETTER_AUTH_SECRET = "change-me-in-production";
const MINIMAX_MODELS = ["MiniMax-M3", "MiniMax-M2.7"] as const;
const MINIMAX_BASE_URLS = [
"https://api.minimax.io/v1",
"https://api.minimaxi.com/v1",
] as const;

/**
* Parse a string env var as boolean. Accepts "true"/"1" → true,
Expand Down Expand Up @@ -392,6 +397,11 @@ const envSchema = z.object({
*/
MAIL_WEBMAIL_ADMIN_TOKEN: z.string().optional(),

/** Optional MiniMax configuration forwarded to managed webmail deployments. */
MINIMAX_API_KEY: z.string().trim().min(1).optional(),
MINIMAX_MODEL: z.enum(MINIMAX_MODELS).default(MINIMAX_MODELS[0]),
MINIMAX_BASE_URL: z.enum(MINIMAX_BASE_URLS).default(MINIMAX_BASE_URLS[0]),

/** Enables verbose timing logs for SSH/system checks and environment detection */
SYSTEM_DEBUG_LOGS: envBool(),

Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/modules/mail/webmail/webmail-catalog-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ describe("webmail catalog contract", () => {
}
});

it("declares MiniMax credentials, models, and regional endpoints", () => {
const fields = flattenSettingFields(getAppSettings(template!));
const apiKey = fields.find((field) => field.key === WEBMAIL_SETTING_KEYS.miniMaxApiKey);
const model = fields.find((field) => field.key === WEBMAIL_SETTING_KEYS.miniMaxModel);
const baseUrl = fields.find((field) => field.key === WEBMAIL_SETTING_KEYS.miniMaxBaseUrl);

expect(apiKey?.type).toBe("password");
expect(apiKey?.secret).toBe(true);
expect(model?.options?.map((option) => option.value)).toEqual([
"MiniMax-M3",
"MiniMax-M2.7",
]);
expect(baseUrl?.options?.map((option) => option.value)).toEqual([
"https://api.minimax.io/v1",
"https://api.minimaxi.com/v1",
]);
for (const field of [apiKey, model, baseUrl]) {
expect(field?.installStep).toBe(true);
expect(field?.requiresRedeploy).toBe(true);
}
});

it("generates the two secrets nobody may be asked for", () => {
// SESSION_ENCRYPTION_KEY is generated ONCE at install and reused from the
// project's env on every redeploy — a fresh key signs every operator out.
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/modules/mail/webmail/webmail-install.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import { AppError, getAppEndpoints, safeErrorMessage, type AppTemplate } from "@repo/core";
import { repos, type Domain, type Project } from "@repo/db";
import { env } from "../../../config/env";
import { assertResourceInOrg } from "../../../lib/controller-helpers";
import { pickCanonicalDomainRow } from "../../../lib/public-endpoints";
import type { RequestContext } from "../../../lib/request-context";
Expand Down Expand Up @@ -81,6 +82,9 @@ export const WEBMAIL_SETTING_KEYS = {
smtpHost: "DEFAULT_SMTP_HOST",
smtpPort: "DEFAULT_SMTP_PORT",
trustedOrigins: "TRUSTED_ORIGINS",
miniMaxApiKey: "MINIMAX_API_KEY",
miniMaxModel: "MINIMAX_MODEL",
miniMaxBaseUrl: "MINIMAX_BASE_URL",
} as const;

/**
Expand All @@ -95,6 +99,15 @@ export const WEBMAIL_SETTING_KEYS = {
const OUR_IMAP_PORT = "993";
const OUR_SMTP_PORT = "465";

function miniMaxSettings(service: string): AppSettingChange[] {
if (!env.MINIMAX_API_KEY) return [];
return [
{ service, key: WEBMAIL_SETTING_KEYS.miniMaxApiKey, value: env.MINIMAX_API_KEY },
{ service, key: WEBMAIL_SETTING_KEYS.miniMaxModel, value: env.MINIMAX_MODEL },
{ service, key: WEBMAIL_SETTING_KEYS.miniMaxBaseUrl, value: env.MINIMAX_BASE_URL },
];
}

// ─── Public shapes ───────────────────────────────────────────────────────────

export type WebmailDeployTarget =
Expand Down Expand Up @@ -391,6 +404,7 @@ export async function startWebmailDeploy(
// catalog token — which is what makes a hostname change self-correcting.
value: useProxyVariant ? `https://${input.hostname}` : "",
},
...miniMaxSettings(endpoint.service),
],
deployTarget: input.target.kind === "cloud" ? "cloud" : "server",
serverId: input.target.kind === "self" ? input.target.serverId : undefined,
Expand Down Expand Up @@ -454,6 +468,7 @@ export async function startExternalWebmailDeploy(
key: WEBMAIL_SETTING_KEYS.smtpPort,
value: String(input.backend.smtpPort),
},
...miniMaxSettings(endpoint.service),
],
deployTarget: input.target.deployTarget,
serverId: input.target.serverId,
Expand Down
5 changes: 5 additions & 0 deletions apps/email/scripts/build-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,15 @@ front to terminate TLS and route public traffic to it.
| \`DEFAULT_IMAP_PORT\` | \`993\` | |
| \`DEFAULT_SMTP_HOST\` | \`mail.<email-domain>\` | Pinned SMTP host |
| \`DEFAULT_SMTP_PORT\` | \`587\` | |
| \`MINIMAX_API_KEY\` | unset | Enables AI email generation |
| \`MINIMAX_MODEL\` | \`MiniMax-M3\` | Chat model |
| \`MINIMAX_BASE_URL\` | \`https://api.minimax.io/v1\` | Global or CN API endpoint |
| \`BRANDING_PATH\` | \`./data/branding\` | Where branding lives on disk |
| \`SQLITE_PATH\` | \`./data/zero.db\` | Session DB |
| \`IMAP_DEBUG\` | unset | Verbose IMAP per-op timings |

Set \`MINIMAX_BASE_URL\` to \`https://api.minimaxi.com/v1\` for the CN endpoint.

## What's in the dist

- \`client/\` - pre-built SPA static assets (no build step on this server)
Expand Down
27 changes: 27 additions & 0 deletions apps/email/server/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import {
MINIMAX_BASE_URLS,
MINIMAX_MODELS,
type MiniMaxBaseUrl,
type MiniMaxModel,
} from './lib/minimax';

const IS_PROD = process.env.NODE_ENV === 'production';

Expand Down Expand Up @@ -74,6 +80,19 @@ function int(name: string, fallback: number): number {
return n;
}

function choice<const Values extends readonly string[]>(
name: string,
values: Values,
fallback: Values[number],
): Values[number] {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
if (!values.includes(raw as Values[number])) {
throw new Error(`Env var ${name} must be one of: ${values.join(', ')}`);
}
return raw as Values[number];
}

export interface Env {
NODE_ENV: 'development' | 'production' | 'test';
PORT: number;
Expand All @@ -90,6 +109,10 @@ export interface Env {
DEFAULT_SMTP_HOST: string | undefined;
DEFAULT_SMTP_PORT: number;

MINIMAX_API_KEY: string | undefined;
MINIMAX_MODEL: MiniMaxModel;
MINIMAX_BASE_URL: MiniMaxBaseUrl;

SQLITE_PATH: string;
/**
* Filesystem root for white-label config. The directory contains
Expand Down Expand Up @@ -134,6 +157,10 @@ export const env: Env = {
DEFAULT_SMTP_HOST: process.env.DEFAULT_SMTP_HOST,
DEFAULT_SMTP_PORT: int('DEFAULT_SMTP_PORT', 587),

MINIMAX_API_KEY: process.env.MINIMAX_API_KEY?.trim() || undefined,
MINIMAX_MODEL: choice('MINIMAX_MODEL', MINIMAX_MODELS, MINIMAX_MODELS[0]),
MINIMAX_BASE_URL: choice('MINIMAX_BASE_URL', MINIMAX_BASE_URLS, MINIMAX_BASE_URLS[0]),

SQLITE_PATH: optional('SQLITE_PATH', './data/zero.db'),
BRANDING_PATH: optional('BRANDING_PATH', './data/mail-branding'),
BRANDING_ADMIN_TOKEN: required('BRANDING_ADMIN_TOKEN'),
Expand Down
72 changes: 72 additions & 0 deletions apps/email/server/src/lib/minimax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { z } from "zod";

export const MINIMAX_MODELS = ["MiniMax-M3", "MiniMax-M2.7"] as const;
export const MINIMAX_BASE_URLS = [
"https://api.minimax.io/v1",
"https://api.minimaxi.com/v1",
] as const;

export type MiniMaxModel = (typeof MINIMAX_MODELS)[number];
export type MiniMaxBaseUrl = (typeof MINIMAX_BASE_URLS)[number];

export interface MiniMaxConfig {
apiKey: string;
model: MiniMaxModel;
baseUrl: MiniMaxBaseUrl;
}

export interface MiniMaxMessage {
role: "system" | "user" | "assistant";
content: string;
}

const completionSchema = z.object({
choices: z
.array(
z.object({
message: z.object({ content: z.string() }),
}),
)
.min(1),
});

export async function createMiniMaxChatCompletion(
config: MiniMaxConfig,
messages: MiniMaxMessage[],
fetchImpl: typeof fetch = fetch,
): Promise<string> {
const response = await fetchImpl(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: config.model,
messages,
}),
});

if (!response.ok) {
throw new Error(`MiniMax request failed with status ${response.status}.`);
}

let payload: unknown;
try {
payload = await response.json();
} catch {
throw new Error("MiniMax returned an invalid JSON response.");
}

const parsed = completionSchema.safeParse(payload);
if (!parsed.success) {
throw new Error("MiniMax returned an invalid chat completion response.");
}

const content = parsed.data.choices[0]!.message.content.trim();
if (!content) {
throw new Error("MiniMax returned an empty chat completion.");
}

return content;
}
5 changes: 3 additions & 2 deletions apps/email/server/src/trpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { templatesRouter } from './routes/templates';
import { userRouter } from './routes/user';
import { cookiePreferencesRouter } from './routes/cookies';
import { brandingRouter } from './routes/branding';
import { aiRouter } from './routes/ai';
import {
aiRouter,
brainRouter,
bimiRouter,
connectionsRouter,
Expand All @@ -33,8 +33,9 @@ export const appRouter = router({
cookiePreferences: cookiePreferencesRouter,
branding: brandingRouter,

// Stubs - see `routes/stubs.ts`.
ai: aiRouter,

// Stubs - see `routes/stubs.ts`.
brain: brainRouter,
bimi: bimiRouter,
connections: connectionsRouter,
Expand Down
Loading