feat: add /api/translate proxy and expose email body to plugins
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
|
||||
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
|
||||
// provider } here via api.http.post; we forward to a free translation backend
|
||||
// and return { translatedText, detectedSource } in a stable shape.
|
||||
//
|
||||
// Two providers:
|
||||
// - "mymemory" — public MyMemory API, no configuration required. Its
|
||||
// langpair needs an explicit source language, so when the
|
||||
// plugin asks for "auto" we detect it locally first.
|
||||
// - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL
|
||||
// (and optionally LIBRETRANSLATE_API_KEY). Supports native
|
||||
// source auto-detection.
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
// Long bodies are split into ~480-char chunks for MyMemory and translated
|
||||
// sequentially, so allow enough headroom for ~10 round-trips.
|
||||
const TIMEOUT_MS = 25000;
|
||||
|
||||
type Provider = 'mymemory' | 'libretranslate';
|
||||
|
||||
interface TranslateBody {
|
||||
text?: unknown;
|
||||
target?: unknown;
|
||||
source?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
|
||||
interface TranslateResult {
|
||||
translatedText: string;
|
||||
detectedSource?: string;
|
||||
}
|
||||
|
||||
// ─── Lightweight language detection ───────────────────────────
|
||||
//
|
||||
// MyMemory has no auto-detect, so we infer a source language from the text.
|
||||
// Non-Latin scripts are decided by Unicode range; Latin-script European
|
||||
// languages are scored by stop-word frequency. Detection only needs to be good
|
||||
// enough to (a) pick a sensible langpair and (b) let the plugin skip messages
|
||||
// already in the target language.
|
||||
|
||||
const SCRIPT_RANGES: ReadonlyArray<[RegExp, string]> = [
|
||||
[/[-ヿ]/, 'ja'], // Hiragana / Katakana
|
||||
[/[가-]/, 'ko'], // Hangul
|
||||
[/[一-鿿]/, 'zh'], // CJK ideographs (after JP/KR checks)
|
||||
[/[Ѐ-ӿ]/, 'ru'], // Cyrillic (ru vs uk refined below)
|
||||
[/[Ͱ-Ͽ]/, 'el'], // Greek
|
||||
[/[-ۿ]/, 'ar'], // Arabic
|
||||
[/[-]/, 'he'], // Hebrew
|
||||
[/[ऀ-ॿ]/, 'hi'], // Devanagari
|
||||
];
|
||||
|
||||
// Distinctive stop words per Latin-script language from the manifest's option
|
||||
// list. Kept small and high-signal to avoid cross-language collisions.
|
||||
const LATIN_STOPWORDS: Record<string, readonly string[]> = {
|
||||
en: ['the', 'and', 'you', 'that', 'with', 'for', 'this', 'have', 'are'],
|
||||
de: ['der', 'die', 'und', 'das', 'ist', 'nicht', 'mit', 'sie', 'ein', 'auch'],
|
||||
fr: ['les', 'des', 'une', 'est', 'pour', 'que', 'vous', 'dans', 'avec', 'pas'],
|
||||
es: ['que', 'los', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'esta'],
|
||||
it: ['che', 'non', 'per', 'una', 'sono', 'con', 'come', 'questo', 'anche', 'della'],
|
||||
pt: ['que', 'não', 'uma', 'com', 'para', 'como', 'mais', 'você', 'está', 'isso'],
|
||||
nl: ['het', 'een', 'van', 'dat', 'niet', 'met', 'voor', 'aan', 'zijn', 'maar'],
|
||||
pl: ['nie', 'jest', 'się', 'ale', 'oraz', 'tego', 'jak', 'tym', 'przez', 'dla'],
|
||||
sv: ['och', 'att', 'det', 'som', 'för', 'med', 'inte', 'den', 'till', 'har'],
|
||||
no: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
da: ['og', 'det', 'som', 'for', 'med', 'ikke', 'har', 'til', 'denne', 'jeg'],
|
||||
fi: ['että', 'olen', 'tämä', 'kanssa', 'mutta', 'sekä', 'ei', 'on', 'ja', 'jotta'],
|
||||
cs: ['není', 'jsem', 'pro', 'ale', 'jako', 'tento', 'také', 'přes', 'jsou', 'své'],
|
||||
ro: ['este', 'pentru', 'care', 'dar', 'sunt', 'această', 'mai', 'din', 'sau', 'nu'],
|
||||
hu: ['hogy', 'nem', 'egy', 'van', 'ezt', 'vagy', 'mint', 'csak', 'ezzel', 'így'],
|
||||
tr: ['bir', 'için', 'değil', 'bu', 'çok', 'daha', 'ama', 'gibi', 've', 'ile'],
|
||||
};
|
||||
|
||||
function detectLanguage(text: string): string {
|
||||
const sample = text.slice(0, 1000);
|
||||
for (const [range, lang] of SCRIPT_RANGES) {
|
||||
if (range.test(sample)) {
|
||||
// Ukrainian shares Cyrillic with Russian; its unique glyphs decide it.
|
||||
if (lang === 'ru' && /[єіїґ]/.test(sample)) return 'uk';
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
const words = sample.toLowerCase().match(/[a-zà-ÿčśžłńęąółżźć]+/gi) || [];
|
||||
if (words.length === 0) return 'en';
|
||||
const counts: Record<string, number> = {};
|
||||
const wordSet = new Set(words);
|
||||
for (const [lang, stops] of Object.entries(LATIN_STOPWORDS)) {
|
||||
let score = 0;
|
||||
for (const stop of stops) if (wordSet.has(stop)) score += 1;
|
||||
counts[lang] = score;
|
||||
}
|
||||
let best = 'en';
|
||||
let bestScore = -1;
|
||||
for (const [lang, score] of Object.entries(counts)) {
|
||||
if (score > bestScore) {
|
||||
best = lang;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestScore > 0 ? best : 'en';
|
||||
}
|
||||
|
||||
function baseLang(code: string): string {
|
||||
return String(code || '').toLowerCase().split('-')[0];
|
||||
}
|
||||
|
||||
// ─── Chunking ─────────────────────────────────────────────────
|
||||
//
|
||||
// MyMemory's free endpoint caps each request's `q` at 500 characters and
|
||||
// silently returns only the translated prefix beyond that — which is why long
|
||||
// emails came back truncated ("…about Sel…"). We split the text into
|
||||
// line-aware chunks under the limit, translate each, then rejoin so the whole
|
||||
// body is covered.
|
||||
|
||||
const MYMEMORY_CHUNK = 480;
|
||||
|
||||
function chunkText(text: string, max: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let cur = '';
|
||||
const flush = () => {
|
||||
if (cur) {
|
||||
chunks.push(cur);
|
||||
cur = '';
|
||||
}
|
||||
};
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.length > max) {
|
||||
flush();
|
||||
// A single over-long line (e.g. a long URL list): split on words, and
|
||||
// hard-cut any word that is itself longer than the limit.
|
||||
let seg = '';
|
||||
for (const word of line.split(' ')) {
|
||||
const piece = seg ? seg + ' ' + word : word;
|
||||
if (piece.length > max) {
|
||||
if (seg) {
|
||||
chunks.push(seg);
|
||||
seg = '';
|
||||
}
|
||||
if (word.length > max) {
|
||||
for (let i = 0; i < word.length; i += max) chunks.push(word.slice(i, i + max));
|
||||
} else {
|
||||
seg = word;
|
||||
}
|
||||
} else {
|
||||
seg = piece;
|
||||
}
|
||||
}
|
||||
if (seg) chunks.push(seg);
|
||||
continue;
|
||||
}
|
||||
if (cur && cur.length + 1 + line.length > max) flush();
|
||||
cur = cur ? cur + '\n' + line : line;
|
||||
}
|
||||
flush();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ─── Providers ────────────────────────────────────────────────
|
||||
|
||||
async function mymemoryRequest(
|
||||
q: string,
|
||||
langpair: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
const url = new URL('https://api.mymemory.translated.net/get');
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('langpair', langpair);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
headers: { 'User-Agent': 'JMAP-Webmail/1.0 Translate-Plugin' },
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(`MyMemory returned ${res.status}`);
|
||||
}
|
||||
const status = Number(data.responseStatus);
|
||||
if (status && status !== 200) {
|
||||
throw new Error(data.responseDetails || `MyMemory error ${status}`);
|
||||
}
|
||||
const translatedText = data.responseData?.translatedText || '';
|
||||
if (!translatedText) {
|
||||
throw new Error('MyMemory returned no translation');
|
||||
}
|
||||
return translatedText;
|
||||
}
|
||||
|
||||
async function translateMyMemory(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const detected = source === 'auto' || !source ? detectLanguage(text) : baseLang(source);
|
||||
const tgt = baseLang(target);
|
||||
// Nothing to do if already in the target language; the plugin skips display.
|
||||
if (detected === tgt) {
|
||||
return { translatedText: text, detectedSource: detected };
|
||||
}
|
||||
const langpair = `${detected}|${tgt}`;
|
||||
const chunks = chunkText(text, MYMEMORY_CHUNK);
|
||||
// Sequential to stay friendly to MyMemory's free-tier rate limits; emails are
|
||||
// usually one or two chunks.
|
||||
const translated: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
translated.push(await mymemoryRequest(chunk, langpair, signal));
|
||||
}
|
||||
return { translatedText: translated.join('\n'), detectedSource: detected };
|
||||
}
|
||||
|
||||
async function translateLibre(
|
||||
text: string,
|
||||
source: string,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<TranslateResult> {
|
||||
const endpoint = process.env.LIBRETRANSLATE_URL;
|
||||
if (!endpoint) {
|
||||
throw new Error('LibreTranslate is not configured on this server');
|
||||
}
|
||||
const apiKey = process.env.LIBRETRANSLATE_API_KEY;
|
||||
const url = endpoint.replace(/\/+$/, '') + '/translate';
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
q: text,
|
||||
source: source || 'auto',
|
||||
target: baseLang(target),
|
||||
format: 'text',
|
||||
...(apiKey ? { api_key: apiKey } : {}),
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
|
||||
| null;
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(data?.error || `LibreTranslate returned ${res.status}`);
|
||||
}
|
||||
if (!data.translatedText) {
|
||||
throw new Error(data.error || 'LibreTranslate returned no translation');
|
||||
}
|
||||
return {
|
||||
translatedText: data.translatedText,
|
||||
detectedSource: data.detectedLanguage?.language || (source !== 'auto' ? baseLang(source) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Route ────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: TranslateBody;
|
||||
try {
|
||||
body = (await request.json()) as TranslateBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
||||
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
|
||||
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
|
||||
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
|
||||
}
|
||||
if (text.length > MAX_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Text exceeds the ${MAX_CHARS}-character limit` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const result =
|
||||
provider === 'libretranslate'
|
||||
? await translateLibre(text, source, target, controller.signal)
|
||||
: await translateMyMemory(text, source, target, controller.signal);
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({ error: 'Translation timed out' }, { status: 504 });
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Translation failed';
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user