initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Redis} from 'ioredis';
import {Logger} from '~/Logger';
import type {IAssetDeletionQueue, QueuedAssetDeletion} from './IAssetDeletionQueue';
const QUEUE_KEY = 'asset:deletion:queue';
const MAX_RETRIES = 5;
export class AssetDeletionQueue implements IAssetDeletionQueue {
constructor(private readonly redis: Redis) {}
async queueDeletion(item: Omit<QueuedAssetDeletion, 'queuedAt' | 'retryCount'>): Promise<void> {
const fullItem: QueuedAssetDeletion = {
...item,
queuedAt: Date.now(),
retryCount: 0,
};
try {
await this.redis.rpush(QUEUE_KEY, JSON.stringify(fullItem));
Logger.debug({s3Key: item.s3Key, reason: item.reason}, 'Queued asset for deletion');
} catch (error) {
Logger.error({error, item}, 'Failed to queue asset for deletion');
throw error;
}
}
async queueCloudflarePurge(cdnUrl: string): Promise<void> {
const item: QueuedAssetDeletion = {
s3Key: '',
cdnUrl,
reason: 'cdn_purge_only',
queuedAt: Date.now(),
retryCount: 0,
};
try {
await this.redis.rpush(QUEUE_KEY, JSON.stringify(item));
Logger.debug({cdnUrl}, 'Queued CDN URL for purge');
} catch (error) {
Logger.error({error, cdnUrl}, 'Failed to queue CDN URL for purge');
throw error;
}
}
async getBatch(count: number): Promise<Array<QueuedAssetDeletion>> {
if (count <= 0) {
return [];
}
try {
const items = await this.redis.lpop(QUEUE_KEY, count);
if (!items) {
return [];
}
const itemArray = Array.isArray(items) ? items : [items];
return itemArray.map((item) => JSON.parse(item) as QueuedAssetDeletion);
} catch (error) {
Logger.error({error, count}, 'Failed to get batch from asset deletion queue');
throw error;
}
}
async requeueItem(item: QueuedAssetDeletion): Promise<void> {
const retryCount = (item.retryCount ?? 0) + 1;
if (retryCount > MAX_RETRIES) {
Logger.error(
{s3Key: item.s3Key, cdnUrl: item.cdnUrl, retryCount},
'Asset deletion exceeded max retries, dropping from queue',
);
return;
}
const requeuedItem: QueuedAssetDeletion = {
...item,
retryCount,
};
try {
await this.redis.rpush(QUEUE_KEY, JSON.stringify(requeuedItem));
Logger.debug({s3Key: item.s3Key, retryCount}, 'Requeued failed asset deletion');
} catch (error) {
Logger.error({error, item}, 'Failed to requeue asset deletion');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.redis.llen(QUEUE_KEY);
} catch (error) {
Logger.error({error}, 'Failed to get asset deletion queue size');
throw error;
}
}
async clear(): Promise<void> {
try {
await this.redis.del(QUEUE_KEY);
Logger.debug('Cleared asset deletion queue');
} catch (error) {
Logger.error({error}, 'Failed to clear asset deletion queue');
throw error;
}
}
}

View File

@@ -0,0 +1,254 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {
AVATAR_EXTENSIONS,
AVATAR_MAX_SIZE,
EMOJI_EXTENSIONS,
EMOJI_MAX_SIZE,
STICKER_EXTENSIONS,
STICKER_MAX_SIZE,
StickerFormatTypes,
} from '~/Constants';
import {InputValidationError} from '~/Errors';
import type {IMediaService} from './IMediaService';
import type {IStorageService} from './IStorageService';
export class AvatarService {
constructor(
private storageService: IStorageService,
private mediaService: IMediaService,
) {}
async uploadAvatar(params: {
prefix: 'avatars' | 'icons' | 'banners' | 'splashes';
entityId?: bigint;
keyPath?: string;
errorPath: string;
previousKey?: string | null;
base64Image?: string | null;
}): Promise<string | null> {
const {prefix, entityId, keyPath, errorPath, previousKey, base64Image} = params;
const stripAnimationPrefix = (key: string) => (key.startsWith('a_') ? key.substring(2) : key);
const fullKeyPath = keyPath ?? (entityId ? entityId.toString() : '');
if (!base64Image) {
if (previousKey) {
await this.storageService.deleteAvatar({prefix, key: `${fullKeyPath}/${stripAnimationPrefix(previousKey)}`});
}
return null;
}
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.create(errorPath, 'Invalid image data');
}
if (imageBuffer.length > AVATAR_MAX_SIZE) {
throw InputValidationError.create(errorPath, `Image size exceeds ${AVATAR_MAX_SIZE} bytes`);
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !AVATAR_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.create(
errorPath,
`Invalid image format. Supported extensions: ${[...AVATAR_EXTENSIONS].join(', ')}`,
);
}
const imageHash = crypto.createHash('md5').update(Buffer.from(imageBuffer)).digest('hex');
const imageHashShort = imageHash.slice(0, 8);
await this.storageService.uploadAvatar({prefix, key: `${fullKeyPath}/${imageHashShort}`, body: imageBuffer});
if (previousKey) {
await this.storageService.deleteAvatar({prefix, key: `${fullKeyPath}/${stripAnimationPrefix(previousKey)}`});
}
return metadata.format === 'gif' ? `a_${imageHashShort}` : imageHashShort;
}
async uploadAvatarToPath(params: {
bucket: string;
keyPath: string;
errorPath: string;
previousKey?: string | null;
base64Image?: string | null;
}): Promise<string | null> {
const {bucket, keyPath, errorPath, previousKey, base64Image} = params;
const stripAnimationPrefix = (key: string) => (key.startsWith('a_') ? key.substring(2) : key);
if (!base64Image) {
if (previousKey) {
await this.storageService.deleteObject(bucket, `${keyPath}/${stripAnimationPrefix(previousKey)}`);
}
return null;
}
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.create(errorPath, 'Invalid image data');
}
if (imageBuffer.length > AVATAR_MAX_SIZE) {
throw InputValidationError.create(errorPath, `Image size exceeds ${AVATAR_MAX_SIZE} bytes`);
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !AVATAR_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.create(
errorPath,
`Invalid image format. Supported extensions: ${[...AVATAR_EXTENSIONS].join(', ')}`,
);
}
const imageHash = crypto.createHash('md5').update(Buffer.from(imageBuffer)).digest('hex');
const imageHashShort = imageHash.slice(0, 8);
await this.storageService.uploadObject({
bucket,
key: `${keyPath}/${imageHashShort}`,
body: imageBuffer,
});
if (previousKey) {
await this.storageService.deleteObject(bucket, `${keyPath}/${stripAnimationPrefix(previousKey)}`);
}
return metadata.format === 'gif' ? `a_${imageHashShort}` : imageHashShort;
}
async processEmoji(params: {errorPath: string; base64Image: string}): Promise<{
imageBuffer: Uint8Array;
animated: boolean;
}> {
const {errorPath, base64Image} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.create(errorPath, 'Invalid image data');
}
if (imageBuffer.length > EMOJI_MAX_SIZE) {
throw InputValidationError.create(errorPath, `Image size exceeds ${EMOJI_MAX_SIZE} bytes`);
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !EMOJI_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.create(
errorPath,
`Invalid image format. Supported extensions: ${[...EMOJI_EXTENSIONS].join(', ')}`,
);
}
return {imageBuffer, animated: metadata.format === 'gif'};
}
async uploadEmoji(params: {prefix: 'emojis'; emojiId: bigint; imageBuffer: Uint8Array}): Promise<void> {
const {prefix, emojiId, imageBuffer} = params;
await this.storageService.uploadAvatar({prefix, key: emojiId.toString(), body: imageBuffer});
}
async processSticker(params: {errorPath: string; base64Image: string}): Promise<{
imageBuffer: Uint8Array;
formatType: number;
}> {
const {errorPath, base64Image} = params;
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.create(errorPath, 'Invalid image data');
}
if (imageBuffer.length > STICKER_MAX_SIZE) {
throw InputValidationError.create(errorPath, `Image size exceeds ${STICKER_MAX_SIZE} bytes`);
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !EMOJI_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.create(
errorPath,
`Invalid image format. Supported extensions: ${[...STICKER_EXTENSIONS].join(', ')}`,
);
}
let computedFormat: (typeof StickerFormatTypes)[keyof typeof StickerFormatTypes];
switch (metadata.format) {
case 'png': {
computedFormat = StickerFormatTypes.PNG;
break;
}
case 'gif': {
computedFormat = StickerFormatTypes.GIF;
break;
}
default: {
throw InputValidationError.create(errorPath, 'Unknown image format.');
}
}
return {imageBuffer, formatType: computedFormat};
}
async uploadSticker(params: {prefix: 'stickers'; stickerId: bigint; imageBuffer: Uint8Array}): Promise<void> {
const {prefix, stickerId, imageBuffer} = params;
await this.storageService.uploadAvatar({prefix, key: stickerId.toString(), body: imageBuffer});
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '~/Config';
import {FLUXER_USER_AGENT} from '~/Constants';
import type {ICaptchaService, VerifyCaptchaParams} from '~/infrastructure/ICaptchaService';
import {Logger} from '~/Logger';
interface HCaptchaVerifyResponse {
success: boolean;
challenge_ts?: string;
hostname?: string;
credit?: boolean;
'error-codes'?: Array<string>;
score?: number;
score_reason?: Array<string>;
}
export class CaptchaService implements ICaptchaService {
private readonly secretKey: string;
private readonly verifyUrl = 'https://api.hcaptcha.com/siteverify';
constructor() {
if (!Config.captcha.hcaptcha?.secretKey) {
throw new Error('HCAPTCHA_SECRET_KEY is required when CAPTCHA_ENABLED is true');
}
this.secretKey = Config.captcha.hcaptcha.secretKey;
}
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
try {
const params = new URLSearchParams();
params.append('secret', this.secretKey);
params.append('response', token);
if (remoteIp) {
params.append('remoteip', remoteIp);
}
const response = await fetch(this.verifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': FLUXER_USER_AGENT,
},
body: params.toString(),
});
if (!response.ok) {
Logger.error({status: response.status}, 'hCaptcha verify request failed');
return false;
}
const data = (await response.json()) as HCaptchaVerifyResponse;
if (!data.success) {
Logger.warn({errorCodes: data['error-codes']}, 'hCaptcha verification failed');
return false;
}
Logger.debug({hostname: data.hostname}, 'hCaptcha verification successful');
return true;
} catch (error) {
Logger.error({error}, 'Error verifying hCaptcha token');
return false;
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import fs from 'node:fs/promises';
import {createConnection} from 'node:net';
import {Config} from '~/Config';
interface ScanResult {
isClean: boolean;
virus?: string;
}
export class ClamAV {
constructor(
private host = Config.clamav.host!,
private port = Config.clamav.port!,
) {}
async scanFile(filePath: string): Promise<ScanResult> {
const buffer = await fs.readFile(filePath);
return this.scanBuffer(buffer);
}
async scanBuffer(buffer: Buffer): Promise<ScanResult> {
return new Promise((resolve, reject) => {
const socket = createConnection(this.port, this.host);
let response = '';
socket.on('connect', () => {
socket.write('zINSTREAM\0');
const chunkSize = Math.min(buffer.length, 2048);
let offset = 0;
while (offset < buffer.length) {
const remainingBytes = buffer.length - offset;
const bytesToSend = Math.min(chunkSize, remainingBytes);
const sizeBuffer = Buffer.alloc(4);
sizeBuffer.writeUInt32BE(bytesToSend, 0);
socket.write(sizeBuffer);
socket.write(buffer.subarray(offset, offset + bytesToSend));
offset += bytesToSend;
}
const endBuffer = Buffer.alloc(4);
endBuffer.writeUInt32BE(0, 0);
socket.write(endBuffer);
});
socket.on('data', (data) => {
response += data.toString();
});
socket.on('end', () => {
const trimmedResponse = response.trim();
if (trimmedResponse.includes('FOUND')) {
const virusMatch = trimmedResponse.match(/:\s(.+)\sFOUND/);
const virus = virusMatch ? virusMatch[1] : 'Virus detected';
resolve({
isClean: false,
virus,
});
} else if (trimmedResponse.includes('OK')) {
resolve({
isClean: true,
});
} else {
reject(new Error(`Unexpected ClamAV response: ${trimmedResponse}`));
}
});
socket.on('error', (error) => {
reject(new Error(`ClamAV connection failed: ${error.message}`));
});
});
}
}

View File

@@ -0,0 +1,256 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Redis} from 'ioredis';
import {Logger} from '~/Logger';
export interface ICloudflarePurgeQueue {
addUrls(urls: Array<string>): Promise<void>;
getBatch(count: number): Promise<Array<string>>;
getQueueSize(): Promise<number>;
clear(): Promise<void>;
tryConsumeTokens(
requestedTokens: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number>;
getAvailableTokens(maxTokens: number, refillRate: number, refillIntervalMs: number): Promise<number>;
}
export class CloudflarePurgeQueue implements ICloudflarePurgeQueue {
private redis: Redis;
private readonly queueKey = 'cloudflare:purge:urls';
private readonly tokenBucketKey = 'cloudflare:purge:token_bucket';
constructor(redis: Redis) {
this.redis = redis;
}
async addUrls(urls: Array<string>): Promise<void> {
if (urls.length === 0) {
return;
}
const normalized = Array.from(
new Set(urls.map((url) => this.normalizePrefix(url)).filter((prefix) => prefix !== '')),
);
if (normalized.length === 0) {
return;
}
try {
await this.redis.sadd(this.queueKey, ...normalized);
Logger.debug({count: normalized.length}, 'Added prefixes to Cloudflare purge queue');
} catch (error) {
Logger.error({error, urls}, 'Failed to add prefixes to Cloudflare purge queue');
throw error;
}
}
private normalizePrefix(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (trimmed === '') {
return '';
}
try {
const parsed = new URL(trimmed);
return `${parsed.host}${parsed.pathname}`;
} catch {
const [withoutQuery] = trimmed.split(/[?#]/);
return withoutQuery.replace(/^https?:\/\//, '');
}
}
async getBatch(count: number): Promise<Array<string>> {
if (count <= 0) {
return [];
}
try {
const urls = await this.redis.spop(this.queueKey, count);
return Array.isArray(urls) ? urls : urls ? [urls] : [];
} catch (error) {
Logger.error({error, count}, 'Failed to get batch from Cloudflare purge queue');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.redis.scard(this.queueKey);
} catch (error) {
Logger.error({error}, 'Failed to get Cloudflare purge queue size');
throw error;
}
}
async clear(): Promise<void> {
try {
await this.redis.del(this.queueKey);
Logger.debug('Cleared Cloudflare purge queue');
} catch (error) {
Logger.error({error}, 'Failed to clear Cloudflare purge queue');
throw error;
}
}
async tryConsumeTokens(
requestedTokens: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number> {
try {
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local requested = tonumber(ARGV[2])
local maxTokens = tonumber(ARGV[3])
local refillRate = tonumber(ARGV[4])
local refillInterval = tonumber(ARGV[5])
local ttl = tonumber(ARGV[6])
-- Get current bucket state
local bucketData = redis.call("GET", key)
local tokens
local lastRefill
if bucketData then
local parsed = cjson.decode(bucketData)
tokens = parsed.tokens
lastRefill = parsed.lastRefill
-- Calculate tokens to add based on time elapsed
local elapsed = now - lastRefill
local tokensToAdd = math.floor(elapsed / refillInterval) * refillRate
if tokensToAdd > 0 then
tokens = math.min(maxTokens, tokens + tokensToAdd)
lastRefill = now
end
else
-- Initialize bucket with full tokens
tokens = maxTokens
lastRefill = now
end
-- Try to consume tokens
local consumed = 0
if tokens >= requested then
consumed = requested
tokens = tokens - requested
elseif tokens > 0 then
consumed = tokens
tokens = 0
end
-- Save updated state
local newData = cjson.encode({tokens = tokens, lastRefill = lastRefill})
redis.call("SET", key, newData, "EX", ttl)
return consumed
`;
const consumed = (await this.redis.eval(
luaScript,
1,
this.tokenBucketKey,
Date.now().toString(),
requestedTokens.toString(),
maxTokens.toString(),
refillRate.toString(),
refillIntervalMs.toString(),
'3600',
)) as number;
Logger.debug({requested: requestedTokens, consumed}, 'Tried to consume tokens from bucket');
return consumed;
} catch (error) {
Logger.error({error, requestedTokens}, 'Failed to consume tokens');
throw error;
}
}
async getAvailableTokens(maxTokens: number, refillRate: number, refillIntervalMs: number): Promise<number> {
try {
const now = Date.now();
const bucketData = await this.redis.get(this.tokenBucketKey);
if (!bucketData) {
return maxTokens;
}
const parsed = JSON.parse(bucketData);
let tokens = parsed.tokens;
const lastRefill = parsed.lastRefill;
const elapsed = now - lastRefill;
const tokensToAdd = Math.floor(elapsed / refillIntervalMs) * refillRate;
if (tokensToAdd > 0) {
tokens = Math.min(maxTokens, tokens + tokensToAdd);
}
return tokens;
} catch (error) {
Logger.error({error}, 'Failed to get available tokens');
throw error;
}
}
async resetTokenBucket(): Promise<void> {
try {
await this.redis.del(this.tokenBucketKey);
Logger.debug('Reset token bucket');
} catch (error) {
Logger.error({error}, 'Failed to reset token bucket');
throw error;
}
}
}
export class NoopCloudflarePurgeQueue implements ICloudflarePurgeQueue {
async addUrls(_urls: Array<string>): Promise<void> {}
async getBatch(_count: number): Promise<Array<string>> {
return [];
}
async getQueueSize(): Promise<number> {
return 0;
}
async clear(): Promise<void> {}
async tryConsumeTokens(
_requestedTokens: number,
_maxTokens: number,
_refillRate: number,
_refillIntervalMs: number,
): Promise<number> {
return 0;
}
async getAvailableTokens(maxTokens: number, _refillRate: number, _refillIntervalMs: number): Promise<number> {
return maxTokens;
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import type {VoiceRegionMetadata, VoiceServerRecord} from '~/voice/VoiceModel';
import type {ILiveKitService} from './ILiveKitService';
interface CreateTokenParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface UpdateParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface DisconnectParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
}
interface UpdateParticipantPermissionsParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}
export class DisabledLiveKitService implements ILiveKitService {
async createToken(_params: CreateTokenParams): Promise<{token: string; endpoint: string}> {
throw new Error('Voice is disabled');
}
async updateParticipant(_params: UpdateParticipantParams): Promise<void> {}
async updateParticipantPermissions(_params: UpdateParticipantPermissionsParams): Promise<void> {}
async disconnectParticipant(_params: DisconnectParticipantParams): Promise<void> {}
getDefaultRegionId(): string | null {
return null;
}
getRegionMetadata(): Array<VoiceRegionMetadata> {
return [];
}
getServer(_regionId: string, _serverId: string): VoiceServerRecord | null {
return null;
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import type {IVirusScanService, VirusScanResult} from './IVirusScanService';
export class DisabledVirusScanService implements IVirusScanService {
private cachedVirusHashes = new Set<string>();
async initialize(): Promise<void> {}
async scanFile(filePath: string): Promise<VirusScanResult> {
const fileHash = crypto.createHash('sha256').update(filePath).digest('hex');
return {
isClean: true,
fileHash,
};
}
async scanBuffer(buffer: Buffer, _filename: string): Promise<VirusScanResult> {
const fileHash = crypto.createHash('sha256').update(buffer).digest('hex');
return {
isClean: true,
fileHash,
};
}
async isVirusHashCached(fileHash: string): Promise<boolean> {
return this.cachedVirusHashes.has(fileHash);
}
async cacheVirusHash(fileHash: string): Promise<void> {
this.cachedVirusHashes.add(fileHash);
}
}

View File

@@ -0,0 +1,236 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IUserRepository} from '~/user/IUserRepository';
interface GenerateDiscriminatorParams {
username: string;
requestedDiscriminator?: number;
isPremium?: boolean;
}
interface GenerateDiscriminatorResult {
discriminator: number;
available: boolean;
}
interface ResolveUsernameChangeParams {
currentUsername: string;
currentDiscriminator: number;
newUsername: string;
isPremium?: boolean;
requestedDiscriminator?: number;
}
interface ResolveUsernameChangeResult {
username: string;
discriminator: number;
}
export class UsernameNotAvailableError extends Error {
constructor(message = 'Username not available') {
super(message);
this.name = 'UsernameNotAvailableError';
}
}
export interface IDiscriminatorService {
generateDiscriminator(params: GenerateDiscriminatorParams): Promise<GenerateDiscriminatorResult>;
isDiscriminatorAvailableForUsername(username: string, discriminator: number): Promise<boolean>;
resolveUsernameChange(params: ResolveUsernameChangeParams): Promise<ResolveUsernameChangeResult>;
}
export class DiscriminatorService implements IDiscriminatorService {
private static readonly LOCK_TTL_MS = 5000;
private static readonly LOCK_RETRY_DELAY_MS = 50;
private static readonly LOCK_MAX_WAIT_MS = 10000;
private static readonly DISCRIM_CACHE_TTL_S = 30;
constructor(
private userRepository: IUserRepository,
private cacheService: ICacheService,
) {}
async generateDiscriminator(params: GenerateDiscriminatorParams): Promise<GenerateDiscriminatorResult> {
const {username, requestedDiscriminator, isPremium = false} = params;
const usernameLower = username.toLowerCase();
const lockKey = `discrim-lock:${usernameLower}`;
const acquired = await this.acquireLockWithRetry(lockKey);
if (!acquired) {
return {discriminator: -1, available: false};
}
try {
if (isPremium && requestedDiscriminator !== undefined) {
const isAvailable = await this.isDiscriminatorAvailable(usernameLower, requestedDiscriminator);
if (isAvailable) {
await this.cacheClaimedDiscriminator(usernameLower, requestedDiscriminator);
return {discriminator: requestedDiscriminator, available: true};
}
return {discriminator: requestedDiscriminator, available: false};
}
const discriminator = await this.generateRandomDiscriminator(usernameLower);
if (discriminator === -1) {
return {discriminator: -1, available: false};
}
await this.cacheClaimedDiscriminator(usernameLower, discriminator);
return {discriminator, available: true};
} finally {
await this.releaseLock(lockKey);
}
}
async isDiscriminatorAvailableForUsername(username: string, discriminator: number): Promise<boolean> {
const usernameLower = username.toLowerCase();
const lockKey = `discrim-lock:${usernameLower}`;
const acquired = await this.acquireLockWithRetry(lockKey);
if (!acquired) {
return false;
}
try {
return await this.isDiscriminatorAvailable(usernameLower, discriminator);
} finally {
await this.releaseLock(lockKey);
}
}
async resolveUsernameChange(params: ResolveUsernameChangeParams): Promise<ResolveUsernameChangeResult> {
const {currentUsername, currentDiscriminator, newUsername, isPremium = false, requestedDiscriminator} = params;
if (
currentUsername.toLowerCase() === newUsername.toLowerCase() &&
(requestedDiscriminator === undefined || requestedDiscriminator === currentDiscriminator)
) {
return {username: newUsername, discriminator: currentDiscriminator};
}
const result = await this.generateDiscriminator({
username: newUsername,
requestedDiscriminator: isPremium ? requestedDiscriminator : undefined,
isPremium,
});
if (!result.available || result.discriminator === -1) {
throw new UsernameNotAvailableError();
}
return {username: newUsername, discriminator: result.discriminator};
}
private async acquireLockWithRetry(lockKey: string): Promise<boolean> {
const startTime = Date.now();
while (Date.now() - startTime < DiscriminatorService.LOCK_MAX_WAIT_MS) {
const acquired = await this.acquireLock(lockKey);
if (acquired) {
return true;
}
await this.sleep(DiscriminatorService.LOCK_RETRY_DELAY_MS);
}
return false;
}
private async acquireLock(lockKey: string): Promise<boolean> {
try {
const lockValue = `${Date.now()}-${Math.random()}`;
const ttlSeconds = Math.ceil(DiscriminatorService.LOCK_TTL_MS / 1000);
const result = await this.cacheService.set(lockKey, lockValue, ttlSeconds);
return result !== null;
} catch (_error) {
return false;
}
}
private async releaseLock(lockKey: string): Promise<void> {
try {
await this.cacheService.delete(lockKey);
} catch (_error) {}
}
private async isDiscriminatorAvailable(username: string, discriminator: number): Promise<boolean> {
const cacheKey = `discrim-claimed:${username}:${discriminator}`;
const cached = await this.cacheService.get(cacheKey);
if (cached !== null) {
return false;
}
const user = await this.userRepository.findByUsernameDiscriminator(username, discriminator);
return user === null;
}
private async generateRandomDiscriminator(username: string): Promise<number> {
const takenDiscriminators = await this.userRepository.findDiscriminatorsByUsername(username);
const cachedDiscriminators = await this.getCachedDiscriminators(username);
const allTaken = new Set([...takenDiscriminators, ...cachedDiscriminators]);
if (allTaken.size >= 9999) {
return -1;
}
for (let attempts = 0; attempts < 10; attempts++) {
const randomDiscrim = Math.floor(Math.random() * 9999) + 1;
if (!allTaken.has(randomDiscrim)) {
return randomDiscrim;
}
}
for (let i = 1; i <= 9999; i++) {
if (!allTaken.has(i)) {
return i;
}
}
return -1;
}
private async cacheClaimedDiscriminator(username: string, discriminator: number): Promise<void> {
const cacheKey = `discrim-claimed:${username}:${discriminator}`;
await this.cacheService.set(cacheKey, '1', DiscriminatorService.DISCRIM_CACHE_TTL_S);
}
private async getCachedDiscriminators(username: string): Promise<Set<number>> {
const discriminators = new Set<number>();
for (let i = 0; i <= 9999; i++) {
const cacheKey = `discrim-claimed:${username}:${i}`;
const cached = await this.cacheService.get(cacheKey);
if (cached !== null) {
discriminators.add(i);
}
}
return discriminators;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,660 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {beforeEach, describe, expect, it} from 'vitest';
import {EmailI18nService} from './EmailI18nService';
import type {EmailTemplateKey, EmailTemplateVariables} from './email_i18n';
describe('EmailI18nService', () => {
let service: EmailI18nService;
beforeEach(() => {
service = new EmailI18nService();
});
describe('Template Rendering', () => {
describe('passwordReset', () => {
const variables = {
username: 'testuser',
resetUrl: 'https://fluxer.app/reset/abc123',
};
it('should render in en-US', () => {
const result = service.getTemplate('passwordReset', 'en-US', variables);
expect(result.subject).toBe('Reset your Fluxer password');
expect(result.body).toContain('Hello testuser');
expect(result.body).toContain('https://fluxer.app/reset/abc123');
expect(result.body).toContain('This link will expire in 1 hour');
});
it('should render in es-ES', () => {
const result = service.getTemplate('passwordReset', 'es-ES', variables);
expect(result.subject).toContain('Restablece');
expect(result.body).toContain('testuser');
expect(result.body).toContain('https://fluxer.app/reset/abc123');
});
it('should render in ja', () => {
const result = service.getTemplate('passwordReset', 'ja', variables);
expect(result.subject).toContain('パスワード');
expect(result.body).toContain('testuser');
expect(result.body).toContain('https://fluxer.app/reset/abc123');
});
});
describe('emailVerification', () => {
const variables = {
username: 'newuser',
verifyUrl: 'https://fluxer.app/verify/xyz789',
};
it('should render in en-US', () => {
const result = service.getTemplate('emailVerification', 'en-US', variables);
expect(result.subject).toBe('Verify your Fluxer email address');
expect(result.body).toContain('Hello newuser');
expect(result.body).toContain('https://fluxer.app/verify/xyz789');
expect(result.body).toContain('This link will expire in 24 hours');
});
it('should render in es-ES', () => {
const result = service.getTemplate('emailVerification', 'es-ES', variables);
expect(result.subject).toContain('Verifica');
expect(result.body).toContain('newuser');
expect(result.body).toContain('https://fluxer.app/verify/xyz789');
});
it('should render in ja', () => {
const result = service.getTemplate('emailVerification', 'ja', variables);
expect(result.subject).toContain('メール');
expect(result.body).toContain('newuser');
expect(result.body).toContain('https://fluxer.app/verify/xyz789');
});
});
describe('ipAuthorization', () => {
const variables = {
username: 'secureuser',
authUrl: 'https://fluxer.app/auth/ip123',
ipAddress: '192.168.1.1',
location: 'San Francisco, CA, USA',
};
it('should render in en-US', () => {
const result = service.getTemplate('ipAuthorization', 'en-US', variables);
expect(result.subject).toBe('Authorize login from new IP address');
expect(result.body).toContain('Hello secureuser');
expect(result.body).toContain('192.168.1.1');
expect(result.body).toContain('San Francisco, CA, USA');
expect(result.body).toContain('https://fluxer.app/auth/ip123');
});
it('should render in es-ES', () => {
const result = service.getTemplate('ipAuthorization', 'es-ES', variables);
expect(result.body).toContain('secureuser');
expect(result.body).toContain('192.168.1.1');
expect(result.body).toContain('San Francisco, CA, USA');
});
it('should render in ja', () => {
const result = service.getTemplate('ipAuthorization', 'ja', variables);
expect(result.body).toContain('secureuser');
expect(result.body).toContain('192.168.1.1');
expect(result.body).toContain('San Francisco, CA, USA');
});
});
describe('accountDisabledSuspicious', () => {
it('should render in en-US with reason', () => {
const variables = {
username: 'suspicioususer',
reason: 'Multiple failed login attempts detected',
forgotUrl: 'https://fluxer.app/forgot',
};
const result = service.getTemplate('accountDisabledSuspicious', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer account has been temporarily disabled');
expect(result.body).toContain('Hello suspicioususer');
expect(result.body).toContain('Multiple failed login attempts detected');
expect(result.body).toContain('https://fluxer.app/forgot');
});
it('should render in en-US without reason', () => {
const variables = {
username: 'suspicioususer',
reason: null,
forgotUrl: 'https://fluxer.app/forgot',
};
const result = service.getTemplate('accountDisabledSuspicious', 'en-US', variables);
expect(result.body).toContain('Hello suspicioususer');
expect(result.body).not.toContain('Reason:');
expect(result.body).toContain('https://fluxer.app/forgot');
});
it('should render in es-ES', () => {
const variables = {
username: 'suspicioususer',
reason: 'Activity detected',
forgotUrl: 'https://fluxer.app/forgot',
};
const result = service.getTemplate('accountDisabledSuspicious', 'es-ES', variables);
expect(result.body).toContain('suspicioususer');
});
});
describe('accountTempBanned', () => {
const bannedUntil = new Date('2025-12-10T15:00:00Z');
it('should render in en-US with plural hours', () => {
const variables = {
username: 'banneduser',
reason: 'Spam behavior',
durationHours: 24,
bannedUntil,
termsUrl: 'https://fluxer.app/terms',
guidelinesUrl: 'https://fluxer.app/guidelines',
};
const result = service.getTemplate('accountTempBanned', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer account has been temporarily suspended');
expect(result.body).toContain('Hello banneduser');
expect(result.body).toContain('24 hours');
expect(result.body).toContain('Spam behavior');
});
it('should render in en-US with singular hour', () => {
const variables = {
username: 'banneduser',
reason: null,
durationHours: 1,
bannedUntil,
termsUrl: 'https://fluxer.app/terms',
guidelinesUrl: 'https://fluxer.app/guidelines',
};
const result = service.getTemplate('accountTempBanned', 'en-US', variables);
expect(result.body).toContain('1 hour');
expect(result.body).not.toContain('1 hours');
});
it('should render in es-ES', () => {
const variables = {
username: 'banneduser',
reason: 'Spam behavior',
durationHours: 48,
bannedUntil,
termsUrl: 'https://fluxer.app/terms',
guidelinesUrl: 'https://fluxer.app/guidelines',
};
const result = service.getTemplate('accountTempBanned', 'es-ES', variables);
expect(result.body).toContain('banneduser');
});
});
describe('accountScheduledDeletion', () => {
const deletionDate = new Date('2025-12-25T10:00:00Z');
it('should render in en-US', () => {
const variables = {
username: 'deleteduser',
reason: 'Repeated violations',
deletionDate,
termsUrl: 'https://fluxer.app/terms',
guidelinesUrl: 'https://fluxer.app/guidelines',
};
const result = service.getTemplate('accountScheduledDeletion', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer account is scheduled for deletion');
expect(result.body).toContain('Hello deleteduser');
expect(result.body).toContain('Repeated violations');
expect(result.body).toContain('appeals@fluxer.app');
});
it('should render in es-ES', () => {
const variables = {
username: 'deleteduser',
reason: 'Violations',
deletionDate,
termsUrl: 'https://fluxer.app/terms',
guidelinesUrl: 'https://fluxer.app/guidelines',
};
const result = service.getTemplate('accountScheduledDeletion', 'es-ES', variables);
expect(result.body).toContain('deleteduser');
});
});
describe('selfDeletionScheduled', () => {
const deletionDate = new Date('2025-12-15T12:00:00Z');
it('should render in en-US', () => {
const variables = {
username: 'leavinguser',
deletionDate,
};
const result = service.getTemplate('selfDeletionScheduled', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer account deletion has been scheduled');
expect(result.body).toContain('Hello leavinguser');
expect(result.body).toContain('sad to see you go');
expect(result.body).toContain('cancel this deletion');
});
it('should render in es-ES', () => {
const variables = {
username: 'leavinguser',
deletionDate,
};
const result = service.getTemplate('selfDeletionScheduled', 'es-ES', variables);
expect(result.body).toContain('leavinguser');
});
});
describe('inactivityWarning', () => {
const deletionDate = new Date('2025-12-20T10:00:00Z');
const lastActiveDate = new Date('2023-01-15T08:30:00Z');
it('should render in en-US', () => {
const variables = {
username: 'inactiveuser',
deletionDate,
lastActiveDate,
loginUrl: 'https://fluxer.app/login',
};
const result = service.getTemplate('inactivityWarning', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer account will be deleted due to inactivity');
expect(result.body).toContain('Hello inactiveuser');
expect(result.body).toContain('over 2 years');
expect(result.body).toContain('https://fluxer.app/login');
});
it('should render in es-ES', () => {
const variables = {
username: 'inactiveuser',
deletionDate,
lastActiveDate,
loginUrl: 'https://fluxer.app/login',
};
const result = service.getTemplate('inactivityWarning', 'es-ES', variables);
expect(result.body).toContain('inactiveuser');
});
});
describe('harvestCompleted', () => {
const expiresAt = new Date('2025-12-10T10:00:00Z');
it('should render in en-US', () => {
const variables = {
username: 'datauser',
downloadUrl: 'https://fluxer.app/download/abc123',
totalMessages: 12345,
fileSizeMB: 456,
expiresAt,
};
const result = service.getTemplate('harvestCompleted', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer Data Export is Ready');
expect(result.body).toContain('Hello datauser');
expect(result.body).toContain('12,345');
expect(result.body).toContain('456 MB');
expect(result.body).toContain('https://fluxer.app/download/abc123');
});
it('should render in es-ES', () => {
const variables = {
username: 'datauser',
downloadUrl: 'https://fluxer.app/download/abc123',
totalMessages: 54321,
fileSizeMB: 789,
expiresAt,
};
const result = service.getTemplate('harvestCompleted', 'es-ES', variables);
expect(result.body).toContain('datauser');
expect(result.body).toContain('https://fluxer.app/download/abc123');
});
});
describe('unbanNotification', () => {
it('should render in en-US', () => {
const variables = {
username: 'unbanneduser',
reason: 'Appeal approved',
};
const result = service.getTemplate('unbanNotification', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer account suspension has been lifted');
expect(result.body).toContain('Hello unbanneduser');
expect(result.body).toContain('Good news');
expect(result.body).toContain('Appeal approved');
});
it('should render in es-ES', () => {
const variables = {
username: 'unbanneduser',
reason: 'Appeal approved',
};
const result = service.getTemplate('unbanNotification', 'es-ES', variables);
expect(result.body).toContain('unbanneduser');
});
});
describe('scheduledDeletionNotification', () => {
const deletionDate = new Date('2025-12-30T10:00:00Z');
it('should render in en-US', () => {
const variables = {
username: 'scheduser',
deletionDate,
reason: 'Terms violation',
};
const result = service.getTemplate('scheduledDeletionNotification', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer account is scheduled for deletion');
expect(result.body).toContain('Hello scheduser');
expect(result.body).toContain('Terms violation');
expect(result.body).toContain('appeals@fluxer.app');
});
it('should render in es-ES', () => {
const variables = {
username: 'scheduser',
deletionDate,
reason: 'Terms violation',
};
const result = service.getTemplate('scheduledDeletionNotification', 'es-ES', variables);
expect(result.body).toContain('scheduser');
});
});
describe('giftChargebackNotification', () => {
it('should render in en-US', () => {
const variables = {
username: 'giftuser',
};
const result = service.getTemplate('giftChargebackNotification', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer Premium gift has been revoked');
expect(result.body).toContain('Hello giftuser');
expect(result.body).toContain('chargeback');
expect(result.body).toContain('revoked');
});
it('should render in es-ES', () => {
const variables = {
username: 'giftuser',
};
const result = service.getTemplate('giftChargebackNotification', 'es-ES', variables);
expect(result.body).toContain('giftuser');
});
});
describe('reportResolved', () => {
it('should render in en-US', () => {
const variables = {
username: 'reporter',
reportId: 'RPT-12345',
publicComment: 'We have taken action on the reported content.',
};
const result = service.getTemplate('reportResolved', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer report has been reviewed');
expect(result.body).toContain('Hello reporter');
expect(result.body).toContain('RPT-12345');
expect(result.body).toContain('We have taken action on the reported content.');
});
it('should render in es-ES', () => {
const variables = {
username: 'reporter',
reportId: 'RPT-54321',
publicComment: 'Action taken.',
};
const result = service.getTemplate('reportResolved', 'es-ES', variables);
expect(result.body).toContain('reporter');
expect(result.body).toContain('RPT-54321');
});
});
describe('registrationApproved', () => {
it('should render in en-US', () => {
const variables = {
username: 'newapproveduser',
channelsUrl: 'https://fluxer.app/channels',
};
const result = service.getTemplate('registrationApproved', 'en-US', variables);
expect(result.subject).toBe('Your Fluxer registration has been approved');
expect(result.body).toContain('Hello newapproveduser');
expect(result.body).toContain('Great news');
expect(result.body).toContain('https://fluxer.app/channels');
});
it('should render in es-ES', () => {
const variables = {
username: 'newapproveduser',
channelsUrl: 'https://fluxer.app/channels',
};
const result = service.getTemplate('registrationApproved', 'es-ES', variables);
expect(result.body).toContain('newapproveduser');
expect(result.body).toContain('https://fluxer.app/channels');
});
});
});
describe('Locale handling', () => {
it('should fall back to en-US for unsupported locale', () => {
const variables = {
username: 'testuser',
resetUrl: 'https://fluxer.app/reset',
};
const result = service.getTemplate('passwordReset', 'unsupported-locale', variables);
expect(result.subject).toBe('Reset your Fluxer password');
});
it('should fall back to en-US for null locale', () => {
const variables = {
username: 'testuser',
resetUrl: 'https://fluxer.app/reset',
};
const result = service.getTemplate('passwordReset', null, variables);
expect(result.subject).toBe('Reset your Fluxer password');
});
it('should handle all supported locales without error', () => {
const supportedLocales = [
'en-US',
'en-GB',
'ar',
'bg',
'cs',
'da',
'de',
'el',
'es-ES',
'es-419',
'fi',
'fr',
'he',
'hi',
'hr',
'hu',
'id',
'it',
'ja',
'ko',
'lt',
'nl',
'no',
'pl',
'pt-BR',
'ro',
'ru',
'sv-SE',
'th',
'tr',
'uk',
'vi',
'zh-CN',
'zh-TW',
];
const variables = {
username: 'testuser',
resetUrl: 'https://fluxer.app/reset',
};
supportedLocales.forEach((locale) => {
expect(() => {
const result = service.getTemplate('passwordReset', locale, variables);
expect(result.subject).toBeTruthy();
expect(result.body).toBeTruthy();
}).not.toThrow();
});
});
});
describe('Date and number formatting', () => {
it('should format date according to locale', () => {
const date = new Date('2025-12-03T15:30:00Z');
const enUSResult = service.formatDate(date, 'en-US');
const esESResult = service.formatDate(date, 'es-ES');
expect(enUSResult).toBeTruthy();
expect(esESResult).toBeTruthy();
expect(enUSResult).not.toBe(esESResult);
});
it('should format numbers according to locale', () => {
const number = 123456.78;
const enUSResult = service.formatNumber(number, 'en-US');
const deResult = service.formatNumber(number, 'de');
expect(enUSResult).toContain('123');
expect(deResult).toContain('123');
expect(enUSResult).not.toBe(deResult);
});
});
describe('All templates coverage', () => {
const allTemplates: Array<EmailTemplateKey> = [
'passwordReset',
'emailVerification',
'emailChangeOriginal',
'emailChangeNew',
'emailChangeRevert',
'ipAuthorization',
'accountDisabledSuspicious',
'accountTempBanned',
'accountScheduledDeletion',
'selfDeletionScheduled',
'inactivityWarning',
'harvestCompleted',
'unbanNotification',
'scheduledDeletionNotification',
'giftChargebackNotification',
'reportResolved',
'registrationApproved',
];
it('should have tests for all 17 templates', () => {
expect(allTemplates).toHaveLength(17);
});
it('should render all templates in en-US without errors', () => {
const testVariables: EmailTemplateVariables = {
passwordReset: {username: 'user', resetUrl: 'url'},
emailVerification: {username: 'user', verifyUrl: 'url'},
emailChangeOriginal: {username: 'user', code: '123456', expiresAt: new Date()},
emailChangeNew: {username: 'user', code: '123456', expiresAt: new Date()},
emailChangeRevert: {username: 'user', newEmail: 'new@example.com', revertUrl: 'url'},
ipAuthorization: {
username: 'user',
authUrl: 'url',
ipAddress: '1.1.1.1',
location: 'Location',
},
accountDisabledSuspicious: {username: 'user', reason: 'reason', forgotUrl: 'url'},
accountTempBanned: {
username: 'user',
reason: 'reason',
durationHours: 24,
bannedUntil: new Date(),
termsUrl: 'url',
guidelinesUrl: 'url',
},
accountScheduledDeletion: {
username: 'user',
reason: 'reason',
deletionDate: new Date(),
termsUrl: 'url',
guidelinesUrl: 'url',
},
selfDeletionScheduled: {username: 'user', deletionDate: new Date()},
inactivityWarning: {
username: 'user',
deletionDate: new Date(),
lastActiveDate: new Date(),
loginUrl: 'url',
},
harvestCompleted: {
username: 'user',
downloadUrl: 'url',
totalMessages: 100,
fileSizeMB: 50,
expiresAt: new Date(),
},
unbanNotification: {username: 'user', reason: 'reason'},
scheduledDeletionNotification: {
username: 'user',
deletionDate: new Date(),
reason: 'reason',
},
giftChargebackNotification: {username: 'user'},
reportResolved: {username: 'user', reportId: 'id', publicComment: 'comment'},
dsaReportVerification: {code: '123456', expiresAt: new Date()},
registrationApproved: {username: 'user', channelsUrl: 'url'},
};
allTemplates.forEach((template) => {
const variables = testVariables[template];
expect(() => {
const result = service.getTemplate(template, 'en-US', variables);
expect(result.subject).toBeTruthy();
expect(result.body).toBeTruthy();
expect(result.body.length).toBeGreaterThan(0);
}).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,88 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import MessageFormat from '@messageformat/core';
import {Logger} from '~/Logger';
import {type EmailTemplateKey, type EmailTemplateVariables, getLocaleTranslations, hasLocale} from './email_i18n';
export interface LocalizedEmailTemplate {
subject: string;
body: string;
}
export class EmailI18nService {
private readonly defaultLocale = 'en-US';
private readonly messageFormatCache: Map<string, MessageFormat> = new Map();
getTemplate<T extends EmailTemplateKey>(
templateKey: T,
locale: string | null,
variables: EmailTemplateVariables[T],
): LocalizedEmailTemplate {
const effectiveLocale = this.getEffectiveLocale(locale);
const translations = getLocaleTranslations(effectiveLocale);
const fallbackTranslations = getLocaleTranslations(this.defaultLocale);
const template = translations[templateKey] ?? fallbackTranslations[templateKey];
if (!template) {
throw new Error(`Missing email template ${templateKey} for locale ${effectiveLocale}`);
}
const subjectMf = this.getMessageFormat(effectiveLocale);
const subject = subjectMf.compile(template.subject)(variables);
const bodyMf = this.getMessageFormat(effectiveLocale);
const body = bodyMf.compile(template.body)(variables);
return {subject, body};
}
formatDate(
date: Date,
locale: string | null,
options: Intl.DateTimeFormatOptions = {dateStyle: 'full', timeStyle: 'short'},
): string {
const effectiveLocale = this.getEffectiveLocale(locale);
return date.toLocaleString(effectiveLocale, options);
}
formatNumber(num: number, locale: string | null): string {
const effectiveLocale = this.getEffectiveLocale(locale);
return num.toLocaleString(effectiveLocale);
}
private getMessageFormat(locale: string): MessageFormat {
if (!this.messageFormatCache.has(locale)) {
this.messageFormatCache.set(locale, new MessageFormat(locale));
}
return this.messageFormatCache.get(locale)!;
}
private getEffectiveLocale(locale: string | null): string {
if (!locale) {
return this.defaultLocale;
}
if (!hasLocale(locale)) {
Logger.warn({locale}, 'Unsupported locale for email, falling back to en-US');
return this.defaultLocale;
}
return locale;
}
}

View File

@@ -0,0 +1,371 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import sgMail from '@sendgrid/mail';
import {Config} from '~/Config';
import type {IEmailService} from '~/infrastructure/IEmailService';
import {Logger} from '~/Logger';
import type {IUserRepository} from '~/user/IUserRepository';
import {EmailI18nService} from './EmailI18nService';
export class EmailService implements IEmailService {
private readonly appBaseUrl: string;
private readonly marketingBaseUrl: string;
private readonly emailI18n: EmailI18nService;
constructor(private readonly userRepository: IUserRepository) {
this.appBaseUrl = Config.endpoints.webApp;
this.marketingBaseUrl = Config.endpoints.marketing;
this.emailI18n = new EmailI18nService();
if (this.isEmailEnabled()) {
sgMail.setApiKey(Config.email.apiKey!);
}
}
private isEmailEnabled(): boolean {
return Config.email.enabled && !!(Config.email.apiKey && Config.email.fromEmail);
}
private async sendEmailWithTemplate(
email: string,
subject: string,
body: string,
logContext: string,
): Promise<boolean> {
if (!this.isEmailEnabled()) {
Logger.info(
{logContext},
`Email service disabled. Would have sent:\nTo: ${email}\nSubject: ${subject}\n\n${body}`,
);
return true;
}
return await this.sendEmail(email, subject, body);
}
async sendPasswordResetEmail(
email: string,
username: string,
resetToken: string,
locale: string | null = null,
): Promise<boolean> {
const resetUrl = `${this.appBaseUrl}/reset#token=${resetToken}`;
const template = this.emailI18n.getTemplate('passwordReset', locale, {
username,
resetUrl,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'password reset');
}
async sendEmailVerification(
email: string,
username: string,
verificationToken: string,
locale: string | null = null,
): Promise<boolean> {
const verifyUrl = `${this.appBaseUrl}/verify#token=${verificationToken}`;
const template = this.emailI18n.getTemplate('emailVerification', locale, {
username,
verifyUrl,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'verification');
}
async sendIpAuthorizationEmail(
email: string,
username: string,
authorizationToken: string,
ipAddress: string,
location: string,
locale: string | null = null,
): Promise<boolean> {
const authUrl = `${this.appBaseUrl}/authorize-ip#token=${authorizationToken}`;
const template = this.emailI18n.getTemplate('ipAuthorization', locale, {
username,
authUrl,
ipAddress,
location,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'ip authorization');
}
async sendAccountDisabledForSuspiciousActivityEmail(
email: string,
username: string,
reason: string | null,
locale: string | null = null,
): Promise<boolean> {
const forgotUrl = `${this.appBaseUrl}/forgot`;
const template = this.emailI18n.getTemplate('accountDisabledSuspicious', locale, {
username,
reason,
forgotUrl,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'account disabled suspicious');
}
async sendAccountTempBannedEmail(
email: string,
username: string,
reason: string | null,
durationHours: number,
bannedUntil: Date,
locale: string | null = null,
): Promise<boolean> {
const termsUrl = `${this.marketingBaseUrl}/terms`;
const guidelinesUrl = `${this.marketingBaseUrl}/guidelines`;
const template = this.emailI18n.getTemplate('accountTempBanned', locale, {
username,
reason,
durationHours,
bannedUntil,
termsUrl,
guidelinesUrl,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'account temp banned');
}
async sendAccountScheduledForDeletionEmail(
email: string,
username: string,
reason: string | null,
deletionDate: Date,
locale: string | null = null,
): Promise<boolean> {
const termsUrl = `${this.marketingBaseUrl}/terms`;
const guidelinesUrl = `${this.marketingBaseUrl}/guidelines`;
const template = this.emailI18n.getTemplate('accountScheduledDeletion', locale, {
username,
reason,
deletionDate,
termsUrl,
guidelinesUrl,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'account scheduled deletion');
}
async sendSelfDeletionScheduledEmail(
email: string,
username: string,
deletionDate: Date,
locale: string | null = null,
): Promise<boolean> {
const template = this.emailI18n.getTemplate('selfDeletionScheduled', locale, {
username,
deletionDate,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'self deletion');
}
async sendUnbanNotification(
email: string,
username: string,
reason: string,
locale: string | null = null,
): Promise<boolean> {
const template = this.emailI18n.getTemplate('unbanNotification', locale, {
username,
reason,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'unban notification');
}
async sendScheduledDeletionNotification(
email: string,
username: string,
deletionDate: Date,
reason: string,
locale: string | null = null,
): Promise<boolean> {
const template = this.emailI18n.getTemplate('scheduledDeletionNotification', locale, {
username,
deletionDate,
reason,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'scheduled deletion notification');
}
async sendInactivityWarningEmail(
email: string,
username: string,
deletionDate: Date,
lastActiveDate: Date,
locale: string | null = null,
): Promise<boolean> {
const loginUrl = `${this.appBaseUrl}/login`;
const template = this.emailI18n.getTemplate('inactivityWarning', locale, {
username,
deletionDate,
lastActiveDate,
loginUrl,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'inactivity warning');
}
async sendHarvestCompletedEmail(
email: string,
username: string,
downloadUrl: string,
totalMessages: number,
fileSize: number,
expiresAt: Date,
locale: string | null = null,
): Promise<boolean> {
const fileSizeMB = Number.parseFloat((fileSize / 1024 / 1024).toFixed(2));
const template = this.emailI18n.getTemplate('harvestCompleted', locale, {
username,
downloadUrl,
totalMessages,
fileSizeMB,
expiresAt,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'harvest completed');
}
async sendGiftChargebackNotification(
email: string,
username: string,
locale: string | null = null,
): Promise<boolean> {
const template = this.emailI18n.getTemplate('giftChargebackNotification', locale, {
username,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'gift chargeback notification');
}
async sendReportResolvedEmail(
email: string,
username: string,
reportId: string,
publicComment: string,
locale: string | null = null,
): Promise<boolean> {
const template = this.emailI18n.getTemplate('reportResolved', locale, {
username,
reportId,
publicComment,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'report resolved');
}
async sendDsaReportVerificationCode(
email: string,
code: string,
expiresAt: Date,
locale: string | null = null,
): Promise<boolean> {
const template = this.emailI18n.getTemplate('dsaReportVerification', locale, {
code,
expiresAt,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'dsa report verification');
}
async sendRegistrationApprovedEmail(email: string, username: string, locale: string | null = null): Promise<boolean> {
const channelsUrl = `${this.appBaseUrl}/channels/@me`;
const template = this.emailI18n.getTemplate('registrationApproved', locale, {
username,
channelsUrl,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'registration approved');
}
async sendEmailChangeOriginal(
email: string,
username: string,
code: string,
locale: string | null = null,
): Promise<boolean> {
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const template = this.emailI18n.getTemplate('emailChangeOriginal', locale, {
username,
code,
expiresAt,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'email change original');
}
async sendEmailChangeNew(
email: string,
username: string,
code: string,
locale: string | null = null,
): Promise<boolean> {
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const template = this.emailI18n.getTemplate('emailChangeNew', locale, {
username,
code,
expiresAt,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'email change new');
}
async sendEmailChangeRevert(
email: string,
username: string,
newEmail: string,
token: string,
locale: string | null = null,
): Promise<boolean> {
const revertUrl = `${this.appBaseUrl}/wasntme#token=${token}`;
const template = this.emailI18n.getTemplate('emailChangeRevert', locale, {
username,
revertUrl,
newEmail,
});
return this.sendEmailWithTemplate(email, template.subject, template.body, 'email change revert');
}
private async sendEmail(to: string, subject: string, textBody: string): Promise<boolean> {
if (!this.isEmailEnabled()) return false;
const user = await this.userRepository.findByEmail(to);
if (user?.emailBounced) {
Logger.warn(
{email: to, userId: user.id},
'Refusing to send email to bounced address - email marked as hard bounced',
);
return false;
}
try {
const msg: sgMail.MailDataRequired = {
to,
from: {
email: Config.email.fromEmail,
name: Config.email.fromName,
},
subject,
text: textBody,
trackingSettings: {
clickTracking: {
enable: false,
enableText: false,
},
},
};
await sgMail.send(msg);
Logger.debug({to}, 'Email sent successfully via SendGrid');
return true;
} catch (error) {
Logger.error({error}, 'Error sending email via SendGrid');
return false;
}
}
}

View File

@@ -0,0 +1,447 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID} from '~/BrandedTypes';
import {MessageAttachmentFlags} from '~/Constants';
import type {
RichEmbedAuthorRequest,
RichEmbedFooterRequest,
RichEmbedMediaRequest,
RichEmbedMediaWithMetadata,
RichEmbedRequest,
} from '~/channel/ChannelModel';
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import type {IChannelRepository} from '~/channel/IChannelRepository';
import type {MessageEmbed} from '~/database/CassandraTypes';
import {InputValidationError} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import {Embed, EmbedAuthor, EmbedField, EmbedFooter, EmbedMedia} from '~/Models';
import * as UnfurlerUtils from '~/utils/UnfurlerUtils';
import type {IWorkerService} from '~/worker/IWorkerService';
import type {IMediaService} from './IMediaService';
import type {IUnfurlerService} from './IUnfurlerService';
interface CreateEmbedsParams {
channelId: ChannelID;
messageId: MessageID;
content: string | null;
customEmbeds?: Array<RichEmbedRequest>;
guildId: bigint | null;
isNSFWAllowed: boolean;
}
export class EmbedService {
private readonly MAX_EMBED_CHARACTERS = 6000;
private readonly CACHE_DURATION_SECONDS = 30 * 60;
constructor(
private channelRepository: IChannelRepository,
private cacheService: ICacheService,
private unfurlerService: IUnfurlerService,
private mediaService: IMediaService,
private workerService: IWorkerService,
) {}
async createAndSaveEmbeds(params: CreateEmbedsParams): Promise<Array<MessageEmbed> | null> {
if (params.customEmbeds?.length) {
return await this.processCustomEmbeds(params);
} else {
return await this.processUrlEmbeds(params);
}
}
async getInitialEmbeds(params: {
content: string | null;
customEmbeds?: Array<RichEmbedRequest>;
isNSFWAllowed?: boolean;
}): Promise<{embeds: Array<MessageEmbed> | null; hasUncachedUrls: boolean}> {
if (params.customEmbeds?.length) {
this.validateEmbedSize(params.customEmbeds);
const embeds = await Promise.all(
params.customEmbeds.map((embed) => this.createEmbed(embed, params.isNSFWAllowed ?? false)),
);
return {embeds: embeds.map((embed) => embed.toMessageEmbed()), hasUncachedUrls: false};
}
if (!params.content) {
return {embeds: null, hasUncachedUrls: false};
}
const urls = UnfurlerUtils.extractURLs(params.content);
if (!urls.length) {
return {embeds: null, hasUncachedUrls: false};
}
const {cachedEmbeds, uncachedUrls} = await this.getCachedEmbeds(urls);
return {
embeds: cachedEmbeds.length > 0 ? cachedEmbeds.map((embed) => embed.toMessageEmbed()) : null,
hasUncachedUrls: uncachedUrls.length > 0,
};
}
async enqueueUrlEmbedExtraction(
channelId: ChannelID,
messageId: MessageID,
guildId: bigint | null,
isNSFWAllowed: boolean,
): Promise<void> {
await this.enqueue(channelId, messageId, guildId, isNSFWAllowed);
}
async processUrl(url: string, isNSFWAllowed: boolean = false): Promise<Array<Embed>> {
const embedsData = await this.unfurlerService.unfurl(url, isNSFWAllowed);
return embedsData.map((embedData) => new Embed(this.mapResponseEmbed(embedData)));
}
async cacheEmbeds(url: string, embeds: Array<Embed>): Promise<void> {
if (!embeds.length) return;
const cacheKey = `url-embed:${url}`;
await this.cacheService.set(
cacheKey,
embeds.map((embed) => embed.toMessageEmbed()),
this.CACHE_DURATION_SECONDS,
);
}
private async processCustomEmbeds({
channelId,
messageId,
customEmbeds,
isNSFWAllowed,
}: CreateEmbedsParams): Promise<Array<MessageEmbed> | null> {
if (!customEmbeds?.length) return null;
this.validateEmbedSize(customEmbeds);
const embeds = await Promise.all(customEmbeds.map((embed) => this.createEmbed(embed, isNSFWAllowed)));
await this.updateMessageEmbeds(channelId, messageId, embeds);
return embeds.map((embed) => embed.toMessageEmbed());
}
private async processUrlEmbeds({
channelId,
messageId,
content,
guildId,
isNSFWAllowed,
}: CreateEmbedsParams): Promise<Array<MessageEmbed> | null> {
if (!content) {
await this.updateMessageEmbeds(channelId, messageId, []);
return null;
}
const urls = UnfurlerUtils.extractURLs(content);
if (!urls.length) {
await this.updateMessageEmbeds(channelId, messageId, []);
return null;
}
const {cachedEmbeds, uncachedUrls} = await this.getCachedEmbeds(urls);
if (cachedEmbeds.length) {
await this.updateMessageEmbeds(channelId, messageId, cachedEmbeds);
}
if (uncachedUrls.length) {
await this.enqueue(channelId, messageId, guildId, isNSFWAllowed);
}
return cachedEmbeds.length > 0 ? cachedEmbeds.map((embed) => embed.toMessageEmbed()) : null;
}
private mapResponseEmbed(embed: MessageEmbedResponse): MessageEmbed {
return {
type: embed.type ?? null,
title: embed.title ?? null,
description: embed.description ?? null,
url: embed.url ?? null,
timestamp: embed.timestamp ? new Date(embed.timestamp) : null,
color: embed.color ?? null,
author: embed.author
? {
name: embed.author.name ?? null,
url: embed.author.url ?? null,
icon_url: embed.author.icon_url ?? null,
}
: null,
provider: embed.provider
? {
name: embed.provider.name ?? null,
url: embed.provider.url ?? null,
}
: null,
thumbnail: this.mapResponseMedia(embed.thumbnail),
image: this.mapResponseMedia(embed.image),
video: this.mapResponseMedia(embed.video),
footer: embed.footer
? {
text: embed.footer.text ?? null,
icon_url: embed.footer.icon_url ?? null,
}
: null,
fields:
embed.fields && embed.fields.length > 0
? embed.fields.map((field) => ({
name: field.name ?? null,
value: field.value ?? null,
inline: field.inline ?? false,
}))
: null,
nsfw: embed.nsfw ?? null,
};
}
private mapResponseMedia(media?: MessageEmbedResponse['image']): MessageEmbed['image'] {
if (!media) return null;
return {
url: media.url,
content_type: media.content_type ?? null,
content_hash: media.content_hash ?? null,
width: media.width ?? null,
height: media.height ?? null,
description: media.description ?? null,
placeholder: media.placeholder ?? null,
duration: media.duration ?? null,
flags: media.flags,
};
}
private validateEmbedSize(embeds: Array<RichEmbedRequest>): void {
const totalChars = embeds.reduce((sum, embed) => {
return (
sum +
(embed.title?.length || 0) +
(embed.description?.length || 0) +
(embed.footer?.text?.length || 0) +
(embed.author?.name?.length || 0) +
(embed.fields?.reduce((sum, field) => sum + field.name.length + field.value.length, 0) || 0)
);
}, 0);
if (totalChars > this.MAX_EMBED_CHARACTERS) {
throw InputValidationError.create(
'embeds',
`Embeds must not exceed ${this.MAX_EMBED_CHARACTERS} characters in total`,
);
}
}
private async createEmbed(
embed: RichEmbedRequest & {
image?: RichEmbedMediaWithMetadata | null;
thumbnail?: RichEmbedMediaWithMetadata | null;
},
isNSFWAllowed: boolean,
): Promise<Embed> {
const [author, footer, imageResult, thumbnailResult] = await Promise.all([
this.processAuthor(embed.author ?? undefined, isNSFWAllowed),
this.processFooter(embed.footer ?? undefined, isNSFWAllowed),
this.processMedia(embed.image ?? undefined, isNSFWAllowed),
this.processMedia(embed.thumbnail ?? undefined, isNSFWAllowed),
]);
let nsfw: boolean | null = null;
const hasNSFWImage = imageResult?.nsfw ?? false;
const hasNSFWThumbnail = thumbnailResult?.nsfw ?? false;
if (hasNSFWImage || hasNSFWThumbnail) {
nsfw = true;
}
return new Embed({
type: 'rich',
title: embed.title ?? null,
description: embed.description ?? null,
url: embed.url ?? null,
timestamp: embed.timestamp ?? null,
color: embed.color ?? 0,
footer: footer?.toMessageEmbedFooter() ?? null,
image: imageResult?.media?.toMessageEmbedMedia() ?? null,
thumbnail: thumbnailResult?.media?.toMessageEmbedMedia() ?? null,
video: null,
provider: null,
author: author?.toMessageEmbedAuthor() ?? null,
fields:
embed.fields?.map(({name, value, inline}) =>
new EmbedField({
name: name || null,
value: value || null,
inline: inline ?? false,
}).toMessageEmbedField(),
) ?? null,
nsfw,
});
}
private async processMedia(
request?: RichEmbedMediaRequest | RichEmbedMediaWithMetadata,
isNSFWAllowed?: boolean,
): Promise<{media: EmbedMedia; nsfw: boolean} | null> {
if (!request?.url) return null;
if (request.url.startsWith('attachment://')) {
throw InputValidationError.create(
'embeds',
'Unresolved attachment:// URL detected. This should have been resolved before reaching embed processing.',
);
}
const attachmentMetadata = (request as RichEmbedMediaWithMetadata)._attachmentMetadata;
if (attachmentMetadata) {
return {
media: new EmbedMedia({
url: request.url,
width: attachmentMetadata.width,
height: attachmentMetadata.height,
description: request.description ?? null,
content_type: attachmentMetadata.content_type,
content_hash: attachmentMetadata.content_hash,
placeholder: attachmentMetadata.placeholder,
flags: attachmentMetadata.flags,
duration: attachmentMetadata.duration,
}),
nsfw: attachmentMetadata.nsfw ?? false,
};
}
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: request.url,
isNSFWAllowed: isNSFWAllowed ?? false,
});
if (!metadata) {
return {
media: new EmbedMedia({
url: request.url,
width: null,
height: null,
description: request.description ?? null,
content_type: null,
content_hash: null,
placeholder: null,
flags: 0,
duration: null,
}),
nsfw: false,
};
}
return {
media: new EmbedMedia({
url: request.url,
width: metadata.width ?? null,
height: metadata.height ?? null,
description: request.description ?? null,
content_type: metadata.content_type ?? null,
content_hash: metadata.content_hash ?? null,
placeholder: metadata.placeholder ?? null,
flags:
(metadata.animated ? MessageAttachmentFlags.IS_ANIMATED : 0) |
(metadata.nsfw ? MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA : 0),
duration: metadata.duration ?? null,
}),
nsfw: metadata.nsfw,
};
}
private async processAuthor(author?: RichEmbedAuthorRequest, isNSFWAllowed?: boolean): Promise<EmbedAuthor | null> {
if (!author) return null;
let iconUrl: string | null = null;
if (author.icon_url) {
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: author.icon_url,
isNSFWAllowed: isNSFWAllowed ?? false,
});
if (metadata) iconUrl = author.icon_url;
}
return new EmbedAuthor({
name: author.name,
url: author.url ?? null,
icon_url: iconUrl,
});
}
private async processFooter(footer?: RichEmbedFooterRequest, isNSFWAllowed?: boolean): Promise<EmbedFooter | null> {
if (!footer) return null;
let iconUrl: string | null = null;
if (footer.icon_url) {
const metadata = await this.mediaService.getMetadata({
type: 'external',
url: footer.icon_url,
isNSFWAllowed: isNSFWAllowed ?? false,
});
if (metadata) iconUrl = footer.icon_url;
}
return new EmbedFooter({
text: footer.text,
icon_url: iconUrl,
});
}
private async getCachedEmbeds(
urls: Array<string>,
): Promise<{cachedEmbeds: Array<Embed>; uncachedUrls: Array<string>}> {
const cachedEmbeds: Array<Embed> = [];
const uncachedUrls: Array<string> = [];
for (const url of urls) {
const cacheKey = `url-embed:${url}`;
const cached = await this.cacheService.get<Array<MessageEmbed>>(cacheKey);
if (cached && cached.length > 0) {
for (const embed of cached) {
cachedEmbeds.push(new Embed(embed));
}
} else {
uncachedUrls.push(url);
}
}
return {cachedEmbeds, uncachedUrls};
}
private async enqueue(
channelId: ChannelID,
messageId: MessageID,
guildId: bigint | null,
isNSFWAllowed: boolean,
): Promise<void> {
await this.workerService.addJob('extractEmbeds', {
guildId: guildId ? guildId.toString() : null,
channelId: channelId.toString(),
messageId: messageId.toString(),
isNSFWAllowed,
});
}
private async updateMessageEmbeds(channelId: ChannelID, messageId: MessageID, embeds: Array<Embed>): Promise<void> {
const currentMessage = await this.channelRepository.getMessage(channelId, messageId);
if (!currentMessage) return;
const currentRow = currentMessage.toRow();
const updatedData = {
...currentRow,
embeds: embeds.length > 0 ? embeds.map((embed) => embed.toMessageEmbed()) : null,
version: (currentRow.version ?? 0) + 1,
};
await this.channelRepository.upsertMessage(updatedData, currentRow);
}
}

View File

@@ -0,0 +1,365 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {Config} from '~/Config';
import {AVATAR_EXTENSIONS, AVATAR_MAX_SIZE} from '~/Constants';
import {InputValidationError} from '~/Errors';
import {Logger} from '~/Logger';
import type {IAssetDeletionQueue} from './IAssetDeletionQueue';
import type {IMediaService} from './IMediaService';
import type {IStorageService} from './IStorageService';
export type AssetType = 'avatar' | 'banner' | 'icon' | 'splash' | 'embed_splash';
export type EntityType = 'user' | 'guild' | 'guild_member';
const ASSET_TYPE_TO_PREFIX: Record<AssetType, string> = {
avatar: 'avatars',
banner: 'banners',
icon: 'icons',
splash: 'splashes',
embed_splash: 'embed-splashes',
};
export interface PreparedAssetUpload {
newHash: string | null;
previousHash: string | null;
isAnimated: boolean;
newS3Key: string | null;
previousS3Key: string | null;
newCdnUrl: string | null;
previousCdnUrl: string | null;
height?: number;
width?: number;
imageBuffer?: Uint8Array;
_uploaded: boolean;
}
export interface PrepareAssetUploadOptions {
assetType: AssetType;
entityType: EntityType;
entityId: bigint;
guildId?: bigint;
previousHash: string | null;
base64Image: string | null;
errorPath: string;
}
export interface CommitAssetChangeOptions {
prepared: PreparedAssetUpload;
deferDeletion?: boolean;
}
export class EntityAssetService {
constructor(
private readonly storageService: IStorageService,
private readonly mediaService: IMediaService,
private readonly assetDeletionQueue: IAssetDeletionQueue,
) {}
async prepareAssetUpload(options: PrepareAssetUploadOptions): Promise<PreparedAssetUpload> {
const {assetType, entityType, entityId, guildId, previousHash, base64Image, errorPath} = options;
const s3KeyBase = this.buildS3KeyBase(assetType, entityType, entityId, guildId);
const cdnUrlBase = this.buildCdnUrlBase(assetType, entityType, entityId, guildId);
const previousS3Key = previousHash ? `${s3KeyBase}/${this.stripAnimationPrefix(previousHash)}` : null;
const previousCdnUrl = previousHash ? `${cdnUrlBase}/${previousHash}` : null;
if (!base64Image) {
return {
newHash: null,
previousHash,
isAnimated: false,
newS3Key: null,
previousS3Key,
newCdnUrl: null,
previousCdnUrl,
_uploaded: false,
};
}
const {imageBuffer, format, height, width} = await this.validateAndProcessImage(base64Image, errorPath);
const imageHash = crypto.createHash('md5').update(Buffer.from(imageBuffer)).digest('hex');
const imageHashShort = imageHash.slice(0, 8);
const newHash = format === 'gif' ? `a_${imageHashShort}` : imageHashShort;
const isAnimated = format === 'gif';
const newS3Key = `${s3KeyBase}/${imageHashShort}`;
const newCdnUrl = `${cdnUrlBase}/${newHash}`;
if (newHash === previousHash) {
return {
newHash,
previousHash,
isAnimated,
newS3Key,
previousS3Key,
newCdnUrl,
previousCdnUrl,
height,
width,
_uploaded: false,
imageBuffer,
};
}
await this.uploadToS3(assetType, entityType, newS3Key, imageBuffer);
const exists = await this.verifyAssetExistsWithRetry(assetType, entityType, newS3Key);
if (!exists) {
Logger.error(
{newS3Key, assetType, entityType},
'Asset upload verification failed - object does not exist after upload with retries',
);
throw InputValidationError.create(errorPath, 'Failed to upload image. Please try again.');
}
return {
newHash,
previousHash,
isAnimated,
newS3Key,
previousS3Key,
newCdnUrl,
previousCdnUrl,
height,
width,
_uploaded: true,
imageBuffer,
};
}
async commitAssetChange(options: CommitAssetChangeOptions): Promise<void> {
const {prepared, deferDeletion = true} = options;
if (!prepared.previousHash || !prepared.previousS3Key) {
return;
}
if (prepared.newHash === prepared.previousHash) {
return;
}
if (deferDeletion) {
await this.assetDeletionQueue.queueDeletion({
s3Key: prepared.previousS3Key,
cdnUrl: prepared.previousCdnUrl,
reason: 'asset_replaced',
});
Logger.debug(
{previousS3Key: prepared.previousS3Key, previousCdnUrl: prepared.previousCdnUrl},
'Queued old asset for deferred deletion',
);
} else {
await this.deleteAssetImmediately(prepared.previousS3Key, prepared.previousCdnUrl);
}
}
async rollbackAssetUpload(prepared: PreparedAssetUpload): Promise<void> {
if (!prepared._uploaded || !prepared.newS3Key) {
return;
}
try {
await this.storageService.deleteObject(Config.s3.buckets.cdn, prepared.newS3Key);
Logger.info({newS3Key: prepared.newS3Key}, 'Rolled back asset upload after DB failure');
} catch (error) {
Logger.error({error, newS3Key: prepared.newS3Key}, 'Failed to rollback asset upload - asset may be orphaned');
}
}
async verifyAssetExists(assetType: AssetType, entityType: EntityType, s3Key: string): Promise<boolean> {
try {
const metadata = await this.storageService.getObjectMetadata(Config.s3.buckets.cdn, s3Key);
return metadata !== null;
} catch (error) {
Logger.error({error, s3Key, assetType, entityType}, 'Error checking asset existence');
return false;
}
}
async verifyAssetExistsWithRetry(
assetType: AssetType,
entityType: EntityType,
s3Key: string,
maxRetries: number = 3,
delayMs: number = 500,
): Promise<boolean> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const metadata = await this.storageService.getObjectMetadata(Config.s3.buckets.cdn, s3Key);
if (metadata !== null) {
if (attempt > 1) {
Logger.info({s3Key, assetType, entityType, attempt}, 'Asset verification succeeded after retry');
}
return true;
}
} catch (error) {
Logger.warn({error, s3Key, assetType, entityType, attempt}, 'Asset verification attempt failed');
}
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
}
}
Logger.error({s3Key, assetType, entityType, maxRetries}, 'Asset verification failed after all retries');
return false;
}
getS3KeyForHash(
assetType: AssetType,
entityType: EntityType,
entityId: bigint,
hash: string,
guildId?: bigint,
): string {
const s3KeyBase = this.buildS3KeyBase(assetType, entityType, entityId, guildId);
return `${s3KeyBase}/${this.stripAnimationPrefix(hash)}`;
}
getCdnUrlForHash(
assetType: AssetType,
entityType: EntityType,
entityId: bigint,
hash: string,
guildId?: bigint,
): string {
const cdnUrlBase = this.buildCdnUrlBase(assetType, entityType, entityId, guildId);
return `${cdnUrlBase}/${hash}`;
}
async queueAssetDeletion(
assetType: AssetType,
entityType: EntityType,
entityId: bigint,
hash: string,
guildId?: bigint,
reason: string = 'manual_clear',
): Promise<void> {
const s3Key = this.getS3KeyForHash(assetType, entityType, entityId, hash, guildId);
const cdnUrl = this.getCdnUrlForHash(assetType, entityType, entityId, hash, guildId);
await this.assetDeletionQueue.queueDeletion({
s3Key,
cdnUrl,
reason,
});
}
private stripAnimationPrefix(hash: string): string {
return hash.startsWith('a_') ? hash.substring(2) : hash;
}
private buildS3KeyBase(assetType: AssetType, entityType: EntityType, entityId: bigint, guildId?: bigint): string {
const prefix = ASSET_TYPE_TO_PREFIX[assetType];
if (entityType === 'guild_member') {
if (!guildId) {
throw new Error('guildId is required for guild_member assets');
}
return `guilds/${guildId}/users/${entityId}/${prefix}`;
}
return `${prefix}/${entityId}`;
}
private buildCdnUrlBase(assetType: AssetType, entityType: EntityType, entityId: bigint, guildId?: bigint): string {
const prefix = ASSET_TYPE_TO_PREFIX[assetType];
if (entityType === 'guild_member') {
if (!guildId) {
throw new Error('guildId is required for guild_member assets');
}
return `${Config.endpoints.media}/guilds/${guildId}/users/${entityId}/${prefix}`;
}
return `${Config.endpoints.media}/${prefix}/${entityId}`;
}
private async validateAndProcessImage(
base64Image: string,
errorPath: string,
): Promise<{imageBuffer: Uint8Array; format: string; height?: number; width?: number}> {
const base64Data = base64Image.includes(',') ? base64Image.split(',')[1] : base64Image;
let imageBuffer: Uint8Array;
try {
imageBuffer = new Uint8Array(Buffer.from(base64Data, 'base64'));
} catch {
throw InputValidationError.create(errorPath, 'Invalid image data');
}
if (imageBuffer.length > AVATAR_MAX_SIZE) {
throw InputValidationError.create(errorPath, `Image size exceeds ${AVATAR_MAX_SIZE} bytes`);
}
const metadata = await this.mediaService.getMetadata({
type: 'base64',
base64: base64Data,
isNSFWAllowed: false,
});
if (metadata == null || !AVATAR_EXTENSIONS.has(metadata.format)) {
throw InputValidationError.create(
errorPath,
`Invalid image format. Supported extensions: ${[...AVATAR_EXTENSIONS].join(', ')}`,
);
}
return {imageBuffer, format: metadata.format, height: metadata.height, width: metadata.width};
}
private async uploadToS3(
assetType: AssetType,
entityType: EntityType,
s3Key: string,
imageBuffer: Uint8Array,
): Promise<void> {
try {
Logger.info({s3Key, assetType, entityType, size: imageBuffer.length}, 'Starting asset upload to S3');
await this.storageService.uploadObject({
bucket: Config.s3.buckets.cdn,
key: s3Key,
body: imageBuffer,
});
Logger.info({s3Key, assetType, entityType}, 'Asset upload to S3 completed successfully');
} catch (error) {
Logger.error({error, s3Key, assetType, entityType}, 'Asset upload to S3 failed');
throw new Error(`Failed to upload asset to S3: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private async deleteAssetImmediately(s3Key: string, cdnUrl: string | null): Promise<void> {
try {
await this.storageService.deleteObject(Config.s3.buckets.cdn, s3Key);
Logger.debug({s3Key}, 'Deleted asset from S3');
} catch (error) {
Logger.error({error, s3Key}, 'Failed to delete asset from S3');
}
if (cdnUrl) {
await this.assetDeletionQueue.queueCloudflarePurge(cdnUrl);
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '~/Config';
import {Logger} from '~/Logger';
import type {CallData} from './IGatewayService';
interface GatewayRpcResponse {
result?: unknown;
error?: unknown;
}
const MAX_RETRY_ATTEMPTS = 3;
const MAX_BACKOFF_MS = 5_000;
const INITIAL_BACKOFF_MS = 500;
export class GatewayRpcClient {
private static instance: GatewayRpcClient | null = null;
private readonly endpoint: string;
private constructor() {
this.endpoint = `http://${Config.gateway.rpcHost}:${Config.gateway.rpcPort}/_rpc`;
}
static getInstance(): GatewayRpcClient {
if (!GatewayRpcClient.instance) {
GatewayRpcClient.instance = new GatewayRpcClient();
}
return GatewayRpcClient.instance;
}
async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
Logger.debug(`[gateway-rpc] calling ${method}`);
for (let attempt = 0; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) {
try {
return await this.executeCall(method, params);
} catch (error) {
if (attempt === MAX_RETRY_ATTEMPTS) {
throw error;
}
const backoffMs = this.calculateBackoff(attempt);
Logger.warn({error, attempt: attempt + 1, backoffMs}, '[gateway-rpc] retrying failed request');
await this.delay(backoffMs);
}
}
throw new Error('Unexpected gateway RPC retry failure');
}
private async executeCall<T>(method: string, params: Record<string, unknown>): Promise<T> {
let response: Response;
try {
response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Config.gateway.rpcSecret}`,
},
body: JSON.stringify({
method,
params,
}),
});
} catch (error) {
Logger.error({error}, '[gateway-rpc] request failed to reach gateway');
throw error;
}
const text = await response.text();
let payload: GatewayRpcResponse = {};
if (text.length > 0) {
try {
payload = JSON.parse(text) as GatewayRpcResponse;
} catch (error) {
Logger.error({error, body: text, status: response.status}, '[gateway-rpc] failed to parse response body');
throw new Error('Malformed gateway RPC response');
}
}
if (!response.ok) {
const message =
typeof payload.error === 'string' ? payload.error : `Gateway RPC request failed with status ${response.status}`;
throw new Error(message);
}
if (!Object.hasOwn(payload, 'result')) {
Logger.error({status: response.status, body: payload}, '[gateway-rpc] response missing result value');
throw new Error('Malformed gateway RPC response');
}
return payload.result as T;
}
private calculateBackoff(attempt: number): number {
const multiplier = 2 ** attempt;
return Math.min(INITIAL_BACKOFF_MS * multiplier, MAX_BACKOFF_MS);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async getCall(channelId: string): Promise<CallData | null> {
return this.call<CallData | null>('call.get', {channel_id: channelId});
}
async createCall(
channelId: string,
messageId: string,
region: string,
ringing: Array<string>,
recipients: Array<string>,
): Promise<CallData> {
return this.call<CallData>('call.create', {
channel_id: channelId,
message_id: messageId,
region,
ringing,
recipients,
});
}
async updateCallRegion(channelId: string, region: string): Promise<boolean> {
return this.call('call.update_region', {channel_id: channelId, region});
}
async ringCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
return this.call('call.ring', {channel_id: channelId, recipients});
}
async stopRingingCallRecipients(channelId: string, recipients: Array<string>): Promise<boolean> {
return this.call('call.stop_ringing', {channel_id: channelId, recipients});
}
async deleteCall(channelId: string): Promise<boolean> {
return this.call('call.delete', {channel_id: channelId});
}
async getNodeStats(): Promise<unknown> {
return this.call('process.node_stats', {});
}
}

View File

@@ -0,0 +1,973 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, RoleID, UserID} from '~/BrandedTypes';
import {createChannelID, createRoleID, createUserID} from '~/BrandedTypes';
import type {GatewayDispatchEvent} from '~/Constants';
import {MissingPermissionsError, UnknownGuildError} from '~/Errors';
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
import {Logger} from '~/Logger';
import {GatewayRpcClient} from './GatewayRpcClient';
import type {CallData} from './IGatewayService';
interface DispatchGuildParams {
guildId: GuildID;
event: GatewayDispatchEvent;
data: unknown;
}
interface DispatchPresenceParams {
userId: UserID;
event: GatewayDispatchEvent;
data: unknown;
}
interface InvalidatePushBadgeCountParams {
userId: UserID;
}
interface GuildDataParams {
guildId: GuildID;
userId: UserID;
}
interface GuildMemberParams {
guildId: GuildID;
userId: UserID;
}
interface HasMemberParams {
guildId: GuildID;
userId: UserID;
}
interface GuildMemoryInfo {
guild_id: string | null;
guild_name: string;
guild_icon: string | null;
memory: number;
member_count: number;
session_count: number;
presence_count: number;
}
interface UserPermissionsParams {
guildId: GuildID;
userId: UserID;
channelId?: ChannelID;
}
interface CheckPermissionParams {
guildId: GuildID;
userId: UserID;
permission: bigint;
channelId?: ChannelID;
}
interface CanManageRolesParams {
guildId: GuildID;
userId: UserID;
targetUserId: UserID;
roleId: RoleID;
}
interface AssignableRolesParams {
guildId: GuildID;
userId: UserID;
}
interface MaxRolePositionParams {
guildId: GuildID;
userId: UserID;
}
interface MembersWithRoleParams {
guildId: GuildID;
roleId: RoleID;
}
interface CheckTargetMemberParams {
guildId: GuildID;
userId: UserID;
targetUserId: UserID;
}
interface ViewableChannelsParams {
guildId: GuildID;
userId: UserID;
}
interface CategoryChannelCountParams {
guildId: GuildID;
categoryId: ChannelID;
}
interface ChannelCountParams {
guildId: GuildID;
}
interface UsersToMentionByRolesParams {
guildId: GuildID;
channelId: ChannelID;
roleIds: Array<RoleID>;
authorId: UserID;
}
interface UsersToMentionByUserIdsParams {
guildId: GuildID;
channelId: ChannelID;
userIds: Array<UserID>;
authorId: UserID;
}
interface AllUsersToMentionParams {
guildId: GuildID;
channelId: ChannelID;
authorId: UserID;
}
interface ResolveAllMentionsParams {
guildId: GuildID;
channelId: ChannelID;
authorId: UserID;
mentionEveryone: boolean;
mentionHere: boolean;
roleIds: Array<RoleID>;
userIds: Array<UserID>;
}
interface JoinGuildParams {
userId: UserID;
guildId: GuildID;
}
interface LeaveGuildParams {
userId: UserID;
guildId: GuildID;
}
interface TerminateSessionParams {
userId: UserID;
sessionIdHashes: Array<string>;
}
interface TerminateAllSessionsParams {
userId: UserID;
}
interface UpdateMemberVoiceParams {
guildId: GuildID;
userId: UserID;
mute: boolean;
deaf: boolean;
}
interface DisconnectVoiceUserParams {
guildId: GuildID;
userId: UserID;
connectionId: string | null;
}
interface MoveMemberParams {
guildId: GuildID;
moderatorId: UserID;
userId: UserID;
channelId: ChannelID | null;
connectionId: string | null;
}
interface GuildMemberRpcResponse {
success: boolean;
member_data?: GuildMemberResponse;
}
type PendingRequest<T> = {
resolve: (value: T) => void;
reject: (error: Error) => void;
};
export class GatewayService {
private rpcClient: GatewayRpcClient;
private pendingGuildDataRequests = new Map<string, Array<PendingRequest<GuildResponse>>>();
private pendingGuildMemberRequests = new Map<
string,
Array<PendingRequest<{success: boolean; memberData?: GuildMemberResponse}>>
>();
private pendingPermissionRequests = new Map<string, Array<PendingRequest<boolean>>>();
private batchTimeout: NodeJS.Timeout | null = null;
private readonly BATCH_DELAY_MS = 5;
constructor() {
this.rpcClient = GatewayRpcClient.getInstance();
}
private call<T>(method: string, params: Record<string, unknown>): Promise<T> {
return this.rpcClient.call<T>(method, params);
}
private scheduleBatch(): void {
if (this.batchTimeout) {
return;
}
this.batchTimeout = setTimeout(() => {
void this.processBatch();
}, this.BATCH_DELAY_MS);
}
private async processBatch(): Promise<void> {
this.batchTimeout = null;
const guildDataRequests = new Map(this.pendingGuildDataRequests);
const guildMemberRequests = new Map(this.pendingGuildMemberRequests);
const permissionRequests = new Map(this.pendingPermissionRequests);
const totalGuildDataRequests = Array.from(guildDataRequests.values()).reduce(
(sum, pending) => sum + pending.length,
0,
);
const totalGuildMemberRequests = Array.from(guildMemberRequests.values()).reduce(
(sum, pending) => sum + pending.length,
0,
);
const totalPermissionRequests = Array.from(permissionRequests.values()).reduce(
(sum, pending) => sum + pending.length,
0,
);
if (totalGuildDataRequests > 0 || totalGuildMemberRequests > 0 || totalPermissionRequests > 0) {
Logger.debug(
`[gateway-batch] Processing batch: ${guildDataRequests.size} unique guild.get_data requests (${totalGuildDataRequests} total), ${guildMemberRequests.size} unique guild.get_member requests (${totalGuildMemberRequests} total), ${permissionRequests.size} unique guild.check_permission requests (${totalPermissionRequests} total)`,
);
}
this.pendingGuildDataRequests.clear();
this.pendingGuildMemberRequests.clear();
this.pendingPermissionRequests.clear();
if (guildDataRequests.size > 0) {
await this.processGuildDataBatch(guildDataRequests);
}
if (guildMemberRequests.size > 0) {
await this.processGuildMemberBatch(guildMemberRequests);
}
if (permissionRequests.size > 0) {
await this.processPermissionBatch(permissionRequests);
}
}
private async processGuildDataBatch(requests: Map<string, Array<PendingRequest<GuildResponse>>>): Promise<void> {
const promises = Array.from(requests.entries()).map(async ([key, pending]) => {
try {
const [guildIdStr, userIdStr, skipCheck] = key.split('-');
const guildId = BigInt(guildIdStr) as GuildID;
const userId = BigInt(userIdStr) as UserID;
const skipMembershipCheck = skipCheck === 'skip';
const guildResponse = await this.call<GuildResponse>('guild.get_data', {
guild_id: guildId.toString(),
user_id: skipMembershipCheck ? null : userId.toString(),
});
pending.forEach(({resolve}) => resolve(guildResponse));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
let transformedError: Error;
if (errorMessage === 'guild_not_found') {
transformedError = new UnknownGuildError();
} else if (errorMessage === 'forbidden') {
transformedError = new MissingPermissionsError();
} else {
transformedError = error as Error;
}
pending.forEach(({reject}) => reject(transformedError));
}
});
await Promise.allSettled(promises);
}
private async processGuildMemberBatch(
requests: Map<string, Array<PendingRequest<{success: boolean; memberData?: GuildMemberResponse}>>>,
): Promise<void> {
const promises = Array.from(requests.entries()).map(async ([key, pending]) => {
try {
const [guildIdStr, userIdStr] = key.split('-');
const guildId = BigInt(guildIdStr) as GuildID;
const userId = BigInt(userIdStr) as UserID;
const rpcResult = await this.call<GuildMemberRpcResponse | null>('guild.get_member', {
guild_id: guildId.toString(),
user_id: userId.toString(),
});
if (rpcResult?.success && rpcResult.member_data) {
const result = {success: true, memberData: rpcResult.member_data};
pending.forEach(({resolve}) => resolve(result));
} else {
pending.forEach(({resolve}) => resolve({success: false}));
}
} catch (error) {
pending.forEach(({reject}) => reject(error as Error));
}
});
await Promise.allSettled(promises);
}
private async processPermissionBatch(requests: Map<string, Array<PendingRequest<boolean>>>): Promise<void> {
const promises = Array.from(requests.entries()).map(async ([key, pending]) => {
try {
const [guildIdStr, userIdStr, permissionStr, channelIdStr] = key.split('-');
const guildId = BigInt(guildIdStr) as GuildID;
const userId = BigInt(userIdStr) as UserID;
const permission = BigInt(permissionStr);
const channelId = channelIdStr !== '0' ? (BigInt(channelIdStr) as ChannelID) : undefined;
const result = await this.call<{has_permission: boolean}>('guild.check_permission', {
guild_id: guildId.toString(),
user_id: userId.toString(),
permission: permission.toString(),
channel_id: channelId ? channelId.toString() : '0',
});
pending.forEach(({resolve}) => resolve(result.has_permission));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
let transformedError: Error;
if (errorMessage === 'guild_not_found') {
transformedError = new UnknownGuildError();
} else if (errorMessage === 'forbidden') {
transformedError = new MissingPermissionsError();
} else {
transformedError = error as Error;
}
pending.forEach(({reject}) => reject(transformedError));
}
});
await Promise.allSettled(promises);
}
async dispatchGuild({guildId, event, data}: DispatchGuildParams): Promise<void> {
await this.call('guild.dispatch', {
guild_id: guildId.toString(),
event,
data,
});
}
async dispatchPresence({userId, event, data}: DispatchPresenceParams): Promise<void> {
await this.call('presence.dispatch', {
user_id: userId.toString(),
event,
data,
});
}
async invalidatePushBadgeCount({userId}: InvalidatePushBadgeCountParams): Promise<void> {
await this.call('push.invalidate_badge_count', {
user_id: userId.toString(),
});
}
async getGuildCounts(guildId: GuildID): Promise<{memberCount: number; presenceCount: number}> {
const result = await this.call<{member_count: number; presence_count: number}>('guild.get_counts', {
guild_id: guildId.toString(),
});
return {
memberCount: result.member_count,
presenceCount: result.presence_count,
};
}
async getChannelCount({guildId}: ChannelCountParams): Promise<number> {
const result = await this.call<{count: number}>('guild.get_channel_count', {
guild_id: guildId.toString(),
});
return result.count;
}
async getCategoryChannelCount({guildId, categoryId}: CategoryChannelCountParams): Promise<number> {
const result = await this.call<{count: number}>('guild.get_category_channel_count', {
guild_id: guildId.toString(),
category_id: categoryId.toString(),
});
return result.count;
}
async getGuildData({
guildId,
userId,
skipMembershipCheck,
}: GuildDataParams & {skipMembershipCheck?: boolean}): Promise<GuildResponse> {
const key = `${guildId.toString()}-${userId.toString()}-${skipMembershipCheck ? 'skip' : 'check'}`;
return new Promise<GuildResponse>((resolve, reject) => {
const pending = this.pendingGuildDataRequests.get(key) || [];
pending.push({resolve, reject});
this.pendingGuildDataRequests.set(key, pending);
Logger.debug(
`[gateway-batch] Queued guild.get_data request for guild ${guildId.toString()}, user ${userId.toString()}, total pending: ${pending.length}`,
);
this.scheduleBatch();
});
}
async getGuildMember({
guildId,
userId,
}: GuildMemberParams): Promise<{success: boolean; memberData?: GuildMemberResponse}> {
const key = `${guildId.toString()}-${userId.toString()}`;
return new Promise<{success: boolean; memberData?: GuildMemberResponse}>((resolve, reject) => {
const pending = this.pendingGuildMemberRequests.get(key) || [];
pending.push({resolve, reject});
this.pendingGuildMemberRequests.set(key, pending);
Logger.debug(
`[gateway-batch] Queued guild.get_member request for guild ${guildId.toString()}, user ${userId.toString()}, total pending: ${pending.length}`,
);
this.scheduleBatch();
});
}
async hasGuildMember({guildId, userId}: HasMemberParams): Promise<boolean> {
const result = await this.call<{has_member: boolean}>('guild.has_member', {
guild_id: guildId.toString(),
user_id: userId.toString(),
});
return result.has_member;
}
async listGuildMembers({
guildId,
limit,
offset,
}: {
guildId: GuildID;
limit: number;
offset: number;
}): Promise<{members: Array<GuildMemberResponse>; total: number}> {
const result = await this.call<{members?: Array<GuildMemberResponse>; total?: number}>('guild.list_members', {
guild_id: guildId.toString(),
limit,
offset,
});
return {
members: result.members ?? [],
total: result.total ?? 0,
};
}
async startGuild(guildId: GuildID): Promise<void> {
await this.call('guild.start', {
guild_id: guildId.toString(),
});
}
async stopGuild(guildId: GuildID): Promise<void> {
await this.call('guild.stop', {
guild_id: guildId.toString(),
});
}
async reloadGuild(guildId: GuildID): Promise<void> {
await this.call('guild.reload', {
guild_id: guildId.toString(),
});
}
async reloadAllGuilds(guildIds: Array<GuildID>): Promise<{count: number}> {
const result = await this.call<{count: number}>('guild.reload_all', {
guild_ids: guildIds.map((id) => id.toString()),
});
return {count: result.count};
}
async shutdownGuild(guildId: GuildID): Promise<void> {
await this.call('guild.shutdown', {
guild_id: guildId.toString(),
});
}
async getGuildMemoryStats(limit: number): Promise<{guilds: Array<GuildMemoryInfo>}> {
const result = await this.call<{guilds: Array<GuildMemoryInfo>}>('process.memory_stats', {
limit: limit.toString(),
});
return {
guilds: result.guilds,
};
}
async getUserPermissions({guildId, userId, channelId}: UserPermissionsParams): Promise<bigint> {
const result = await this.call<{permissions: string}>('guild.get_user_permissions', {
guild_id: guildId.toString(),
user_id: userId.toString(),
channel_id: channelId ? channelId.toString() : '0',
});
return BigInt(result.permissions);
}
async checkPermission({guildId, userId, permission, channelId}: CheckPermissionParams): Promise<boolean> {
const key = `${guildId.toString()}-${userId.toString()}-${permission.toString()}-${channelId?.toString() || '0'}`;
return new Promise<boolean>((resolve, reject) => {
const pending = this.pendingPermissionRequests.get(key) || [];
pending.push({resolve, reject});
this.pendingPermissionRequests.set(key, pending);
Logger.debug(
`[gateway-batch] Queued guild.check_permission request for guild ${guildId.toString()}, user ${userId.toString()}, channel ${channelId?.toString() || 'none'}, permission ${permission.toString()}, total pending: ${pending.length}`,
);
this.scheduleBatch();
});
}
async canManageRoles({guildId, userId, targetUserId, roleId}: CanManageRolesParams): Promise<boolean> {
const result = await this.call<{can_manage: boolean}>('guild.can_manage_roles', {
guild_id: guildId.toString(),
user_id: userId.toString(),
target_user_id: targetUserId.toString(),
role_id: roleId.toString(),
});
return result.can_manage;
}
async canManageRole({guildId, userId, roleId}: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean> {
const result = await this.call<{can_manage: boolean}>('guild.can_manage_role', {
guild_id: guildId.toString(),
user_id: userId.toString(),
role_id: roleId.toString(),
});
return result.can_manage;
}
async getAssignableRoles({guildId, userId}: AssignableRolesParams): Promise<Array<RoleID>> {
const result = await this.call<{role_ids: Array<string>}>('guild.get_assignable_roles', {
guild_id: guildId.toString(),
user_id: userId.toString(),
});
return result.role_ids.map((id: string) => createRoleID(BigInt(id)));
}
async getUserMaxRolePosition({guildId, userId}: MaxRolePositionParams): Promise<number> {
const result = await this.call<{position: number}>('guild.get_user_max_role_position', {
guild_id: guildId.toString(),
user_id: userId.toString(),
});
return result.position;
}
async getMembersWithRole({guildId, roleId}: MembersWithRoleParams): Promise<Array<UserID>> {
const result = await this.call<{user_ids: Array<string>}>('guild.get_members_with_role', {
guild_id: guildId.toString(),
role_id: roleId.toString(),
});
return result.user_ids.map((id: string) => createUserID(BigInt(id)));
}
async checkTargetMember({guildId, userId, targetUserId}: CheckTargetMemberParams): Promise<boolean> {
const result = await this.call<{can_manage: boolean}>('guild.check_target_member', {
guild_id: guildId.toString(),
user_id: userId.toString(),
target_user_id: targetUserId.toString(),
});
return result.can_manage;
}
async getViewableChannels({guildId, userId}: ViewableChannelsParams): Promise<Array<ChannelID>> {
const result = await this.call<{channel_ids: Array<string>}>('guild.get_viewable_channels', {
guild_id: guildId.toString(),
user_id: userId.toString(),
});
return result.channel_ids.map((id: string) => createChannelID(BigInt(id)));
}
async getUsersToMentionByRoles({
guildId,
channelId,
roleIds,
authorId,
}: UsersToMentionByRolesParams): Promise<Array<UserID>> {
const result = await this.call<{user_ids: Array<string>}>('guild.get_users_to_mention_by_roles', {
guild_id: guildId.toString(),
channel_id: channelId.toString(),
role_ids: roleIds.map((id) => id.toString()),
author_id: authorId.toString(),
});
return result.user_ids.map((id: string) => createUserID(BigInt(id)));
}
async getUsersToMentionByUserIds({
guildId,
channelId,
userIds,
authorId,
}: UsersToMentionByUserIdsParams): Promise<Array<UserID>> {
const result = await this.call<{user_ids: Array<string>}>('guild.get_users_to_mention_by_user_ids', {
guild_id: guildId.toString(),
channel_id: channelId.toString(),
user_ids: userIds.map((id) => id.toString()),
author_id: authorId.toString(),
});
return result.user_ids.map((id: string) => createUserID(BigInt(id)));
}
async getAllUsersToMention({guildId, channelId, authorId}: AllUsersToMentionParams): Promise<Array<UserID>> {
const result = await this.call<{user_ids: Array<string>}>('guild.get_all_users_to_mention', {
guild_id: guildId.toString(),
channel_id: channelId.toString(),
author_id: authorId.toString(),
});
return result.user_ids.map((id: string) => createUserID(BigInt(id)));
}
async resolveAllMentions({
guildId,
channelId,
authorId,
mentionEveryone,
mentionHere,
roleIds,
userIds,
}: ResolveAllMentionsParams): Promise<Array<UserID>> {
const result = await this.call<{user_ids: Array<string>}>('guild.resolve_all_mentions', {
guild_id: guildId.toString(),
channel_id: channelId.toString(),
author_id: authorId.toString(),
mention_everyone: mentionEveryone,
mention_here: mentionHere,
role_ids: roleIds.map((id) => id.toString()),
user_ids: userIds.map((id) => id.toString()),
});
return result.user_ids.map((id: string) => createUserID(BigInt(id)));
}
async getVanityUrlChannel(guildId: GuildID): Promise<ChannelID | null> {
const result = await this.call<{channel_id: string | null}>('guild.get_vanity_url_channel', {
guild_id: guildId.toString(),
});
return result.channel_id ? createChannelID(BigInt(result.channel_id)) : null;
}
async getFirstViewableTextChannel(guildId: GuildID): Promise<ChannelID | null> {
const result = await this.call<{channel_id: string | null}>('guild.get_first_viewable_text_channel', {
guild_id: guildId.toString(),
});
return result.channel_id ? createChannelID(BigInt(result.channel_id)) : null;
}
async joinGuild({userId, guildId}: JoinGuildParams): Promise<void> {
await this.call('presence.join_guild', {
user_id: userId.toString(),
guild_id: guildId.toString(),
});
}
async leaveGuild({userId, guildId}: LeaveGuildParams): Promise<void> {
await this.call('presence.leave_guild', {
user_id: userId.toString(),
guild_id: guildId.toString(),
});
}
async terminateSession({userId, sessionIdHashes}: TerminateSessionParams): Promise<void> {
await this.call('presence.terminate_sessions', {
user_id: userId.toString(),
session_id_hashes: sessionIdHashes,
});
}
async terminateAllSessionsForUser({userId}: TerminateAllSessionsParams): Promise<void> {
await this.call('presence.terminate_all_sessions', {
user_id: userId.toString(),
});
}
async updateMemberVoice({guildId, userId, mute, deaf}: UpdateMemberVoiceParams): Promise<{success: boolean}> {
const result = await this.call<{success: boolean}>('guild.update_member_voice', {
guild_id: guildId.toString(),
user_id: userId.toString(),
mute,
deaf,
});
return {success: result.success};
}
async disconnectVoiceUser({guildId, userId, connectionId}: DisconnectVoiceUserParams): Promise<void> {
await this.call('guild.disconnect_voice_user', {
guild_id: guildId.toString(),
user_id: userId.toString(),
connection_id: connectionId,
});
}
async disconnectVoiceUserIfInChannel({
guildId,
userId,
expectedChannelId,
connectionId,
}: {
guildId: GuildID;
userId: UserID;
expectedChannelId: ChannelID;
connectionId?: string;
}): Promise<{success: boolean; ignored?: boolean}> {
const params: Record<string, string> = {
guild_id: guildId.toString(),
user_id: userId.toString(),
expected_channel_id: expectedChannelId.toString(),
};
if (connectionId) {
params.connection_id = connectionId;
}
const result = await this.call<{success: boolean; ignored?: boolean}>(
'guild.disconnect_voice_user_if_in_channel',
params,
);
return {
success: result.success,
ignored: result.ignored,
};
}
async getVoiceState({
guildId,
userId,
}: {
guildId: GuildID;
userId: UserID;
}): Promise<{channel_id: string | null} | null> {
const result = await this.call<{voice_state: {channel_id: string | null} | null}>('guild.get_voice_state', {
guild_id: guildId.toString(),
user_id: userId.toString(),
});
return result.voice_state;
}
async moveMember({guildId, moderatorId, userId, channelId, connectionId}: MoveMemberParams): Promise<{
success?: boolean;
error?: string;
}> {
const result = await this.call<{success?: boolean; error?: string}>('guild.move_member', {
guild_id: guildId.toString(),
moderator_id: moderatorId.toString(),
user_id: userId.toString(),
channel_id: channelId ? channelId.toString() : null,
connection_id: connectionId,
});
return result;
}
async hasActivePresence(userId: UserID): Promise<boolean> {
const result = await this.call<{has_active: boolean}>('presence.has_active', {
user_id: userId.toString(),
});
return result.has_active;
}
async addTemporaryGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<void> {
await this.call('presence.add_temporary_guild', {
user_id: userId.toString(),
guild_id: guildId.toString(),
});
}
async removeTemporaryGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<void> {
try {
await this.call('presence.remove_temporary_guild', {
user_id: userId.toString(),
guild_id: guildId.toString(),
});
} catch (_error) {}
}
async syncGroupDmRecipients({
userId,
recipientsByChannel,
}: {
userId: UserID;
recipientsByChannel: Record<string, Array<string>>;
}): Promise<void> {
try {
await this.call('presence.sync_group_dm_recipients', {
user_id: userId.toString(),
recipients_by_channel: recipientsByChannel,
});
} catch (_error) {}
}
async switchVoiceRegion({guildId, channelId}: {guildId: GuildID; channelId: ChannelID}): Promise<void> {
await this.call('guild.switch_voice_region', {
guild_id: guildId.toString(),
channel_id: channelId.toString(),
});
}
async disconnectAllVoiceUsersInChannel({
guildId,
channelId,
}: {
guildId: GuildID;
channelId: ChannelID;
}): Promise<{success: boolean; disconnectedCount: number}> {
const result = await this.call<{success: boolean; disconnected_count: number}>(
'guild.disconnect_all_voice_users_in_channel',
{
guild_id: guildId.toString(),
channel_id: channelId.toString(),
},
);
return {
success: result.success,
disconnectedCount: result.disconnected_count,
};
}
async confirmVoiceConnectionFromLiveKit({
guildId,
connectionId,
}: {
guildId: GuildID;
connectionId: string;
}): Promise<{success: boolean; error?: string}> {
const result = await this.call<{success: boolean; error?: string}>('guild.confirm_voice_connection_from_livekit', {
guild_id: guildId.toString(),
connection_id: connectionId,
});
return {
success: result.success,
error: result.error,
};
}
async getCall(channelId: ChannelID): Promise<CallData | null> {
return this.call<CallData | null>('call.get', {channel_id: channelId.toString()});
}
async createCall(
channelId: ChannelID,
messageId: string,
region: string,
ringing: Array<string>,
recipients: Array<string>,
): Promise<CallData> {
return this.call<CallData>('call.create', {
channel_id: channelId.toString(),
message_id: messageId,
region,
ringing,
recipients,
});
}
async updateCallRegion(channelId: ChannelID, region: string): Promise<boolean> {
return this.call<boolean>('call.update_region', {channel_id: channelId.toString(), region});
}
async ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
return this.call<boolean>('call.ring', {channel_id: channelId.toString(), recipients});
}
async stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean> {
return this.call<boolean>('call.stop_ringing', {channel_id: channelId.toString(), recipients});
}
async deleteCall(channelId: ChannelID): Promise<boolean> {
return this.call<boolean>('call.delete', {channel_id: channelId.toString()});
}
async confirmDMCallConnection({
channelId,
connectionId,
}: {
channelId: ChannelID;
connectionId: string;
}): Promise<{success: boolean; error?: string}> {
const result = await this.call<{success: boolean; error?: string}>('call.confirm_connection', {
channel_id: channelId.toString(),
connection_id: connectionId,
});
return {
success: result.success,
error: result.error,
};
}
async disconnectDMCallUserIfInChannel({
channelId,
userId,
connectionId,
}: {
channelId: ChannelID;
userId: UserID;
connectionId?: string;
}): Promise<{success: boolean; ignored?: boolean}> {
const params: Record<string, string> = {
channel_id: channelId.toString(),
user_id: userId.toString(),
};
if (connectionId) {
params.connection_id = connectionId;
}
const result = await this.call<{success: boolean; ignored?: boolean}>('call.disconnect_user_if_in_channel', params);
return {
success: result.success,
ignored: result.ignored,
};
}
async getNodeStats(): Promise<{
status: string;
sessions: number;
guilds: number;
presences: number;
calls: number;
memory: {
total: number;
processes: number;
system: number;
};
process_count: number;
process_limit: number;
uptime_seconds: number;
}> {
return this.call<{
status: string;
sessions: number;
guilds: number;
presences: number;
calls: number;
memory: {total: number; processes: number; system: number};
process_count: number;
process_limit: number;
uptime_seconds: number;
}>('process.node_stats', {});
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface QueuedAssetDeletion {
s3Key: string;
cdnUrl: string | null;
reason: string;
queuedAt?: number;
retryCount?: number;
}
export interface DeletionQueueProcessResult {
deleted: number;
requeued: number;
failed: number;
remaining: number;
}
export interface IAssetDeletionQueue {
queueDeletion(item: Omit<QueuedAssetDeletion, 'queuedAt' | 'retryCount'>): Promise<void>;
queueCloudflarePurge(cdnUrl: string): Promise<void>;
getBatch(count: number): Promise<Array<QueuedAssetDeletion>>;
requeueItem(item: QueuedAssetDeletion): Promise<void>;
getQueueSize(): Promise<number>;
clear(): Promise<void>;
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export abstract class ICacheService {
abstract get<T>(key: string): Promise<T | null>;
abstract set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
abstract delete(key: string): Promise<void>;
abstract getAndDelete<T>(key: string): Promise<T | null>;
abstract exists(key: string): Promise<boolean>;
abstract expire(key: string, ttlSeconds: number): Promise<void>;
abstract ttl(key: string): Promise<number>;
abstract mget<T>(keys: Array<string>): Promise<Array<T | null>>;
abstract mset<T>(entries: Array<{key: string; value: T; ttlSeconds?: number}>): Promise<void>;
abstract deletePattern(pattern: string): Promise<number>;
abstract acquireLock(key: string, ttlSeconds: number): Promise<string | null>;
abstract releaseLock(key: string, token: string): Promise<boolean>;
abstract getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null>;
abstract publish(channel: string, message: string): Promise<void>;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface VerifyCaptchaParams {
token: string;
remoteIp?: string;
}
export interface ICaptchaService {
verify(params: VerifyCaptchaParams): Promise<boolean>;
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface SentEmailRecord {
to: string;
subject: string;
type: string;
timestamp: Date;
metadata: Record<string, string>;
}
export interface IEmailService {
sendPasswordResetEmail(email: string, username: string, resetToken: string, locale?: string | null): Promise<boolean>;
sendEmailVerification(
email: string,
username: string,
verificationToken: string,
locale?: string | null,
): Promise<boolean>;
sendIpAuthorizationEmail(
email: string,
username: string,
authorizationToken: string,
ipAddress: string,
location: string,
locale?: string | null,
): Promise<boolean>;
sendAccountDisabledForSuspiciousActivityEmail(
email: string,
username: string,
reason: string | null,
locale?: string | null,
): Promise<boolean>;
sendAccountTempBannedEmail(
email: string,
username: string,
reason: string | null,
durationHours: number,
bannedUntil: Date,
locale?: string | null,
): Promise<boolean>;
sendAccountScheduledForDeletionEmail(
email: string,
username: string,
reason: string | null,
deletionDate: Date,
locale?: string | null,
): Promise<boolean>;
sendSelfDeletionScheduledEmail(
email: string,
username: string,
deletionDate: Date,
locale?: string | null,
): Promise<boolean>;
sendUnbanNotification(email: string, username: string, reason: string, locale?: string | null): Promise<boolean>;
sendScheduledDeletionNotification(
email: string,
username: string,
deletionDate: Date,
reason: string,
locale?: string | null,
): Promise<boolean>;
sendInactivityWarningEmail(
email: string,
username: string,
deletionDate: Date,
lastActiveDate: Date,
locale?: string | null,
): Promise<boolean>;
sendHarvestCompletedEmail(
email: string,
username: string,
downloadUrl: string,
totalMessages: number,
fileSize: number,
expiresAt: Date,
locale?: string | null,
): Promise<boolean>;
sendGiftChargebackNotification(email: string, username: string, locale?: string | null): Promise<boolean>;
sendReportResolvedEmail(
email: string,
username: string,
reportId: string,
publicComment: string,
locale?: string | null,
): Promise<boolean>;
sendDsaReportVerificationCode(email: string, code: string, expiresAt: Date, locale?: string | null): Promise<boolean>;
sendRegistrationApprovedEmail(email: string, username: string, locale?: string | null): Promise<boolean>;
sendEmailChangeOriginal(email: string, username: string, code: string, locale?: string | null): Promise<boolean>;
sendEmailChangeNew(email: string, username: string, code: string, locale?: string | null): Promise<boolean>;
sendEmailChangeRevert(
email: string,
username: string,
newEmail: string,
token: string,
locale?: string | null,
): Promise<boolean>;
}
export interface ITestEmailService extends IEmailService {
listSentEmails(): Array<SentEmailRecord>;
clearSentEmails(): void;
}

View File

@@ -0,0 +1,246 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, RoleID, UserID} from '~/BrandedTypes';
import type {GatewayDispatchEvent} from '~/Constants';
import type {GuildMemberResponse, GuildResponse} from '~/guild/GuildModel';
interface VoiceState {
user_id: string;
session_id: string;
self_mute: boolean;
self_deaf: boolean;
self_video: boolean;
viewer_stream_key?: string | null;
}
export interface CallData {
channel_id: string;
message_id: string;
region: string;
ringing: Array<string>;
recipients: Array<string>;
voice_states: Array<VoiceState>;
}
export abstract class IGatewayService {
abstract dispatchGuild(params: {guildId: GuildID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
abstract getGuildCounts(guildId: GuildID): Promise<{memberCount: number; presenceCount: number}>;
abstract getChannelCount(params: {guildId: GuildID}): Promise<number>;
abstract startGuild(guildId: GuildID): Promise<void>;
abstract stopGuild(guildId: GuildID): Promise<void>;
abstract reloadGuild(guildId: GuildID): Promise<void>;
abstract reloadAllGuilds(guildIds: Array<GuildID>): Promise<{count: number}>;
abstract shutdownGuild(guildId: GuildID): Promise<void>;
abstract getGuildMemoryStats(limit: number): Promise<{
guilds: Array<{
guild_id: string | null;
guild_name: string;
guild_icon: string | null;
memory: number;
member_count: number;
session_count: number;
presence_count: number;
}>;
}>;
abstract getUsersToMentionByRoles(params: {
guildId: GuildID;
channelId: ChannelID;
roleIds: Array<RoleID>;
authorId: UserID;
}): Promise<Array<UserID>>;
abstract getUsersToMentionByUserIds(params: {
guildId: GuildID;
channelId: ChannelID;
userIds: Array<UserID>;
authorId: UserID;
}): Promise<Array<UserID>>;
abstract getAllUsersToMention(params: {
guildId: GuildID;
channelId: ChannelID;
authorId: UserID;
}): Promise<Array<UserID>>;
abstract getUserPermissions(params: {guildId: GuildID; userId: UserID; channelId?: ChannelID}): Promise<bigint>;
abstract canManageRoles(params: {
guildId: GuildID;
userId: UserID;
targetUserId: UserID;
roleId: RoleID;
}): Promise<boolean>;
abstract canManageRole(params: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean>;
abstract getAssignableRoles(params: {guildId: GuildID; userId: UserID}): Promise<Array<RoleID>>;
abstract getUserMaxRolePosition(params: {guildId: GuildID; userId: UserID}): Promise<number>;
abstract checkTargetMember(params: {guildId: GuildID; userId: UserID; targetUserId: UserID}): Promise<boolean>;
abstract getViewableChannels(params: {guildId: GuildID; userId: UserID}): Promise<Array<ChannelID>>;
abstract getCategoryChannelCount(params: {guildId: GuildID; categoryId: ChannelID}): Promise<number>;
abstract getMembersWithRole(params: {guildId: GuildID; roleId: RoleID}): Promise<Array<UserID>>;
abstract getGuildData(params: {
guildId: GuildID;
userId: UserID;
skipMembershipCheck?: boolean;
}): Promise<GuildResponse>;
abstract getGuildMember(params: {
guildId: GuildID;
userId: UserID;
}): Promise<{success: boolean; memberData?: GuildMemberResponse}>;
abstract hasGuildMember(params: {guildId: GuildID; userId: UserID}): Promise<boolean>;
abstract listGuildMembers(params: {guildId: GuildID; limit: number; offset: number}): Promise<{
members: Array<GuildMemberResponse>;
total: number;
}>;
abstract checkPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
channelId?: ChannelID;
}): Promise<boolean>;
abstract getVanityUrlChannel(guildId: GuildID): Promise<ChannelID | null>;
abstract getFirstViewableTextChannel(guildId: GuildID): Promise<ChannelID | null>;
abstract dispatchPresence(params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void>;
abstract invalidatePushBadgeCount(params: {userId: UserID}): Promise<void>;
abstract joinGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract leaveGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract terminateSession(params: {userId: UserID; sessionIdHashes: Array<string>}): Promise<void>;
abstract terminateAllSessionsForUser(params: {userId: UserID}): Promise<void>;
abstract updateMemberVoice(params: {
guildId: GuildID;
userId: UserID;
mute: boolean;
deaf: boolean;
}): Promise<{success: boolean}>;
abstract disconnectVoiceUser(params: {guildId: GuildID; userId: UserID; connectionId: string}): Promise<void>;
abstract disconnectVoiceUserIfInChannel(params: {
guildId: GuildID;
userId: UserID;
expectedChannelId: ChannelID;
connectionId?: string;
}): Promise<{success: boolean; ignored?: boolean}>;
abstract disconnectAllVoiceUsersInChannel(params: {
guildId: GuildID;
channelId: ChannelID;
}): Promise<{success: boolean; disconnectedCount: number}>;
abstract confirmVoiceConnectionFromLiveKit(params: {
guildId: GuildID;
connectionId: string;
}): Promise<{success: boolean; error?: string}>;
abstract getVoiceState(params: {guildId: GuildID; userId: UserID}): Promise<{channel_id: string | null} | null>;
abstract moveMember(params: {
guildId: GuildID;
moderatorId: UserID;
userId: UserID;
channelId: ChannelID | null;
connectionId: string | null;
}): Promise<{
success?: boolean;
error?: string;
}>;
abstract hasActivePresence(userId: UserID): Promise<boolean>;
abstract addTemporaryGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract removeTemporaryGuild(params: {userId: UserID; guildId: GuildID}): Promise<void>;
abstract syncGroupDmRecipients(params: {
userId: UserID;
recipientsByChannel: Record<string, Array<string>>;
}): Promise<void>;
abstract switchVoiceRegion(params: {guildId: GuildID; channelId: ChannelID}): Promise<void>;
abstract getCall(channelId: ChannelID): Promise<CallData | null>;
abstract createCall(
channelId: ChannelID,
messageId: string,
region: string,
ringing: Array<string>,
recipients: Array<string>,
): Promise<CallData>;
abstract updateCallRegion(channelId: ChannelID, region: string): Promise<boolean>;
abstract ringCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
abstract stopRingingCallRecipients(channelId: ChannelID, recipients: Array<string>): Promise<boolean>;
abstract deleteCall(channelId: ChannelID): Promise<boolean>;
abstract confirmDMCallConnection(params: {
channelId: ChannelID;
connectionId: string;
}): Promise<{success: boolean; error?: string}>;
abstract disconnectDMCallUserIfInChannel(params: {
channelId: ChannelID;
userId: UserID;
connectionId?: string;
}): Promise<{success: boolean; ignored?: boolean}>;
abstract getNodeStats(): Promise<{
status: string;
sessions: number;
guilds: number;
presences: number;
calls: number;
memory: {
total: number;
processes: number;
system: number;
};
process_count: number;
process_limit: number;
uptime_seconds: number;
}>;
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import type {VoiceRegionMetadata, VoiceServerRecord} from '~/voice/VoiceModel';
interface CreateTokenParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
canSpeak?: boolean;
canStream?: boolean;
canVideo?: boolean;
}
interface UpdateParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface UpdateParticipantPermissionsParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}
interface DisconnectParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
}
export abstract class ILiveKitService {
abstract createToken(params: CreateTokenParams): Promise<{token: string; endpoint: string}>;
abstract updateParticipant(params: UpdateParticipantParams): Promise<void>;
abstract updateParticipantPermissions(params: UpdateParticipantPermissionsParams): Promise<void>;
abstract disconnectParticipant(params: DisconnectParticipantParams): Promise<void>;
abstract getDefaultRegionId(): string | null;
abstract getRegionMetadata(): Array<VoiceRegionMetadata>;
abstract getServer(regionId: string, serverId: string): VoiceServerRecord | null;
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export type MediaProxyMetadataRequest =
| {
type: 'external';
url: string;
with_base64?: boolean;
isNSFWAllowed: boolean;
}
| {
type: 'upload';
upload_filename: string;
isNSFWAllowed: boolean;
}
| {
type: 'base64';
base64: string;
isNSFWAllowed: boolean;
}
| {
type: 's3';
bucket: string;
key: string;
with_base64?: boolean;
isNSFWAllowed: boolean;
};
export interface MediaProxyMetadataResponse {
format: string;
content_type: string;
content_hash: string;
size: number;
width?: number;
height?: number;
duration?: number;
placeholder?: string;
base64?: string;
animated?: boolean;
nsfw: boolean;
nsfw_probability?: number;
nsfw_predictions?: Record<string, number>;
}
export abstract class IMediaService {
abstract getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null>;
abstract getExternalMediaProxyURL(url: string): string;
abstract getThumbnail(uploadFilename: string): Promise<Buffer | null>;
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface CounterParams {
name: string;
dimensions?: Record<string, string>;
value?: number;
}
export interface GaugeParams {
name: string;
dimensions?: Record<string, string>;
value: number;
}
export interface HistogramParams {
name: string;
dimensions?: Record<string, string>;
valueMs: number;
}
export interface CrashParams {
guildId: string;
stacktrace: string;
}
export interface BatchMetric {
type: 'counter' | 'gauge' | 'histogram';
name: string;
dimensions?: Record<string, string>;
value?: number;
valueMs?: number;
}
export interface IMetricsService {
counter(params: CounterParams): void;
gauge(params: GaugeParams): void;
histogram(params: HistogramParams): void;
crash(params: CrashParams): void;
batch(metrics: Array<BatchMetric>): void;
isEnabled(): boolean;
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetTime: Date;
retryAfter?: number;
retryAfterDecimal?: number;
global?: boolean;
}
export interface RateLimitConfig {
maxAttempts: number;
windowMs: number;
identifier: string;
}
export interface BucketConfig {
limit: number;
windowMs: number;
exemptFromGlobal?: boolean;
}
export interface IRateLimitService {
checkLimit(config: RateLimitConfig): Promise<RateLimitResult>;
checkBucketLimit(bucket: string, config: BucketConfig): Promise<RateLimitResult>;
checkGlobalLimit(identifier: string, limit: number): Promise<RateLimitResult>;
resetLimit(identifier: string): Promise<void>;
getRemainingAttempts(identifier: string, windowMs: number): Promise<number>;
getResetTime(identifier: string, windowMs: number): Promise<Date>;
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface ISMSService {
startVerification(phone: string): Promise<void>;
checkVerification(phone: string, code: string): Promise<boolean>;
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Readable} from 'node:stream';
export interface IStorageService {
uploadObject(params: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
expiresAt?: Date;
}): Promise<void>;
deleteObject(bucket: string, key: string): Promise<void>;
getObjectMetadata(bucket: string, key: string): Promise<{contentLength: number; contentType: string} | null>;
readObject(bucket: string, key: string): Promise<Uint8Array>;
streamObject(params: {bucket: string; key: string; range?: string}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null>;
writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void>;
copyObject(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void>;
copyObjectWithJpegProcessing(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null>;
moveObject(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void>;
getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise<string>;
createBucket(bucket: string, allowPublicAccess?: boolean): Promise<void>;
purgeBucket(bucket: string): Promise<void>;
uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void>;
deleteAvatar(params: {prefix: string; key: string}): Promise<void>;
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Context} from 'hono';
import type {TenorCategoryTagResponse, TenorGifResponse} from '~/tenor/TenorModel';
export interface ITenorService {
search(params: {q: string; locale: string; ctx: Context}): Promise<Array<TenorGifResponse>>;
registerShare(params: {id: string; q: string; locale: string; ctx: Context}): Promise<void>;
getFeatured(params: {locale: string; ctx: Context}): Promise<{
gifs: Array<TenorGifResponse>;
categories: Array<TenorCategoryTagResponse>;
}>;
getTrendingGifs(params: {locale: string; ctx: Context}): Promise<Array<TenorGifResponse>>;
suggest(params: {q: string; locale: string; ctx: Context}): Promise<Array<string>>;
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
export abstract class IUnfurlerService {
abstract unfurl(url: string, isNSFWAllowed?: boolean): Promise<Array<MessageEmbedResponse>>;
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export interface VirusScanResult {
isClean: boolean;
threat?: string;
fileHash: string;
}
export abstract class IVirusScanService {
abstract initialize(): Promise<void>;
abstract scanFile(filePath: string): Promise<VirusScanResult>;
abstract scanBuffer(buffer: Buffer, filename: string): Promise<VirusScanResult>;
abstract isVirusHashCached(fileHash: string): Promise<boolean>;
abstract cacheVirusHash(fileHash: string): Promise<void>;
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID} from '~/BrandedTypes';
export abstract class IVoiceRoomStore {
abstract pinRoomServer(
guildId: GuildID | undefined,
channelId: ChannelID,
regionId: string,
serverId: string,
endpoint: string,
): Promise<void>;
abstract getPinnedRoomServer(
guildId: GuildID | undefined,
channelId: ChannelID,
): Promise<{regionId: string; serverId: string; endpoint: string} | null>;
abstract deleteRoomServer(guildId: GuildID | undefined, channelId: ChannelID): Promise<void>;
abstract getRegionOccupancy(regionId: string): Promise<Array<string>>;
abstract getServerOccupancy(regionId: string, serverId: string): Promise<Array<string>>;
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export class InMemoryCoalescer {
private pending = new Map<string, Promise<unknown>>();
async coalesce<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = this.pending.get(key) as Promise<T> | undefined;
if (existing) {
return existing;
}
const promise = (async () => {
try {
return await fn();
} finally {
this.pending.delete(key);
}
})();
this.pending.set(key, promise);
return promise;
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID} from '~/BrandedTypes';
export class InMemoryVoiceRoomStore {
async pinRoomServer(
_guildId: GuildID | undefined,
_channelId: ChannelID,
_regionId: string,
_serverId: string,
_endpoint: string,
): Promise<void> {}
async getPinnedRoomServer(
_guildId: GuildID | undefined,
_channelId: ChannelID,
): Promise<{regionId: string; serverId: string; endpoint: string} | null> {
return null;
}
async deleteRoomServer(_guildId: GuildID | undefined, _channelId: ChannelID): Promise<void> {}
async getRegionOccupancy(_regionId: string): Promise<Array<string>> {
return [];
}
async getServerOccupancy(_regionId: string, _serverId: string): Promise<Array<string>> {
return [];
}
}

View File

@@ -0,0 +1,365 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AccessToken, RoomServiceClient, TrackSource, TrackType} from 'livekit-server-sdk';
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {Logger} from '~/Logger';
import type {VoiceRegionMetadata, VoiceServerRecord} from '~/voice/VoiceModel';
import type {VoiceTopology} from '~/voice/VoiceTopology';
import {ILiveKitService} from './ILiveKitService';
interface CreateTokenParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
canSpeak?: boolean;
canStream?: boolean;
canVideo?: boolean;
}
interface UpdateParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}
interface DisconnectParticipantParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
}
interface UpdateParticipantPermissionsParams {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}
interface ServerClientConfig {
endpoint: string;
apiKey: string;
apiSecret: string;
roomServiceClient: RoomServiceClient;
}
export class LiveKitService extends ILiveKitService {
private serverClients: Map<string, Map<string, ServerClientConfig>> = new Map();
private topology: VoiceTopology;
private static readonly DEFAULT_PUBLISH_SOURCES = [
TrackSource.CAMERA,
TrackSource.MICROPHONE,
TrackSource.SCREEN_SHARE,
TrackSource.SCREEN_SHARE_AUDIO,
];
constructor(topology: VoiceTopology) {
super();
if (!Config.voice.enabled) {
throw new Error('Voice is not enabled. Set VOICE_ENABLED=true to use voice features.');
}
this.topology = topology;
this.refreshServerClients();
this.topology.registerSubscriber(() => {
try {
this.refreshServerClients();
} catch (error) {
Logger.error({error}, 'Failed to refresh LiveKit server clients after topology update');
}
});
}
async createToken(params: CreateTokenParams): Promise<{token: string; endpoint: string}> {
const {
userId,
guildId,
channelId,
connectionId,
regionId,
serverId,
deaf = false,
canSpeak = true,
canStream = true,
canVideo = true,
} = params;
const server = this.resolveServerClient(regionId, serverId);
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const metadata: Record<string, string> = {
user_id: userId.toString(),
channel_id: channelId.toString(),
connection_id: connectionId,
region_id: regionId,
server_id: serverId,
};
if (guildId !== undefined) {
metadata.guild_id = guildId.toString();
} else {
metadata.dm_call = 'true';
}
const canPublishSources = LiveKitService.computePublishSources({canSpeak, canStream, canVideo});
const accessToken = new AccessToken(server.apiKey, server.apiSecret, {
identity: participantIdentity,
metadata: JSON.stringify(metadata),
});
accessToken.addGrant({
roomJoin: true,
room: roomName,
canPublish: !deaf && canPublishSources.length > 0,
canSubscribe: !deaf,
canPublishSources,
});
const token = await accessToken.toJwt();
return {token, endpoint: server.endpoint};
}
private static computePublishSources(permissions: {
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}): Array<TrackSource> {
const sources: Array<TrackSource> = [];
if (permissions.canSpeak) {
sources.push(TrackSource.MICROPHONE);
}
if (permissions.canVideo) {
sources.push(TrackSource.CAMERA);
}
if (permissions.canStream) {
sources.push(TrackSource.SCREEN_SHARE);
sources.push(TrackSource.SCREEN_SHARE_AUDIO);
}
return sources;
}
async updateParticipant(params: UpdateParticipantParams): Promise<void> {
const {userId, guildId, channelId, connectionId, regionId, serverId, mute, deaf} = params;
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const server = this.resolveServerClient(regionId, serverId);
try {
const participants = await server.roomServiceClient.listParticipants(roomName);
const participant = participants.find((p) => p.identity === participantIdentity);
if (!participant) {
return;
}
if (mute !== undefined && participant.tracks) {
for (const track of participant.tracks) {
if (track.type === TrackType.AUDIO) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid!, mute);
}
}
}
if (deaf !== undefined) {
await server.roomServiceClient.updateParticipant(roomName, participantIdentity, undefined, {
canPublish: !deaf,
canSubscribe: !deaf,
canPublishSources: LiveKitService.DEFAULT_PUBLISH_SOURCES,
});
}
} catch (error) {
Logger.error({error}, 'Error updating LiveKit participant');
}
}
async updateParticipantPermissions(params: UpdateParticipantPermissionsParams): Promise<void> {
const {userId, guildId, channelId, connectionId, regionId, serverId, canSpeak, canStream, canVideo} = params;
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const server = this.resolveServerClient(regionId, serverId);
try {
const participants = await server.roomServiceClient.listParticipants(roomName);
const participant = participants.find((p) => p.identity === participantIdentity);
if (!participant) {
Logger.warn({participantIdentity, roomName}, 'Participant not found for permission update');
return;
}
const canPublishSources = LiveKitService.computePublishSources({canSpeak, canStream, canVideo});
await server.roomServiceClient.updateParticipant(roomName, participantIdentity, undefined, {
canPublish: canPublishSources.length > 0,
canPublishSources,
});
if (!canStream && participant.tracks) {
for (const track of participant.tracks) {
if (track.source === TrackSource.SCREEN_SHARE || track.source === TrackSource.SCREEN_SHARE_AUDIO) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid!, true);
}
}
}
if (!canSpeak && participant.tracks) {
for (const track of participant.tracks) {
if (track.source === TrackSource.MICROPHONE) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid!, true);
}
}
}
if (!canVideo && participant.tracks) {
for (const track of participant.tracks) {
if (track.source === TrackSource.CAMERA) {
await server.roomServiceClient.mutePublishedTrack(roomName, participantIdentity, track.sid!, true);
}
}
}
Logger.info({participantIdentity, roomName, canSpeak, canStream, canVideo}, 'Updated participant permissions');
} catch (error) {
Logger.error({error}, 'Error updating LiveKit participant permissions');
}
}
async disconnectParticipant(params: DisconnectParticipantParams): Promise<void> {
const {userId, guildId, channelId, connectionId, regionId, serverId} = params;
const roomName = this.getRoomName(guildId, channelId);
const participantIdentity = this.getParticipantIdentity(userId, connectionId);
const server = this.resolveServerClient(regionId, serverId);
try {
await server.roomServiceClient.removeParticipant(roomName, participantIdentity);
} catch (error) {
if (error instanceof Error && 'status' in error && (error as {status: number}).status === 404) {
Logger.debug({participantIdentity, roomName}, 'LiveKit participant already disconnected');
return;
}
Logger.error({error}, 'Error disconnecting LiveKit participant');
}
}
async listParticipants(params: {
guildId?: GuildID;
channelId: ChannelID;
regionId: string;
serverId: string;
}): Promise<Array<{identity: string}>> {
const {guildId, channelId, regionId, serverId} = params;
const roomName = this.getRoomName(guildId, channelId);
const server = this.resolveServerClient(regionId, serverId);
try {
const participants = await server.roomServiceClient.listParticipants(roomName);
return participants.map((participant) => ({
identity: participant.identity,
}));
} catch (error) {
Logger.error({error}, 'Error listing LiveKit participants');
return [];
}
}
getDefaultRegionId(): string | null {
return this.topology.getDefaultRegionId();
}
getRegionMetadata(): Array<VoiceRegionMetadata> {
return this.topology.getRegionMetadataList();
}
getServer(regionId: string, serverId: string): VoiceServerRecord | null {
return this.topology.getServer(regionId, serverId);
}
private getRoomName(guildId: GuildID | undefined, channelId: ChannelID): string {
if (guildId === undefined) {
return `dm_channel_${channelId}`;
}
return `guild_${guildId}_channel_${channelId}`;
}
private getParticipantIdentity(userId: UserID, connectionId: string): string {
return `user_${userId}_${connectionId}`;
}
private resolveServerClient(regionId: string, serverId: string): ServerClientConfig {
const region = this.serverClients.get(regionId);
if (!region) {
throw new Error(`Unknown LiveKit region: ${regionId}`);
}
const server = region.get(serverId);
if (!server) {
throw new Error(`Unknown LiveKit server: ${regionId}/${serverId}`);
}
return server;
}
private refreshServerClients(): void {
const newMap: Map<string, Map<string, ServerClientConfig>> = new Map();
const regions = this.topology.getAllRegions();
for (const region of regions) {
const servers = this.topology.getServersForRegion(region.id);
const serverMap: Map<string, ServerClientConfig> = new Map();
for (const server of servers) {
serverMap.set(server.serverId, {
endpoint: server.endpoint,
apiKey: server.apiKey,
apiSecret: server.apiSecret,
roomServiceClient: new RoomServiceClient(server.endpoint, server.apiKey, server.apiSecret),
});
}
newMap.set(region.id, serverMap);
}
this.serverClients = newMap;
}
}

View File

@@ -0,0 +1,406 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {WebhookEvent} from 'livekit-server-sdk';
import {WebhookReceiver} from 'livekit-server-sdk';
import {Logger} from '~/Logger';
import type {IUserRepository} from '~/user/IUserRepository';
import type {VoiceTopology} from '~/voice/VoiceTopology';
import type {IGatewayService} from './IGatewayService';
import type {ILiveKitService} from './ILiveKitService';
import type {IVoiceRoomStore} from './IVoiceRoomStore';
import {isDMRoom, parseParticipantIdentity, parseParticipantMetadataWithRaw, parseRoomName} from './VoiceRoomContext';
const FREE_MAX_WIDTH = 1280;
const FREE_MAX_HEIGHT = 720;
export class LiveKitWebhookService {
private receivers: Map<string, WebhookReceiver>;
private serverMap: Map<string, {regionId: string; serverId: string}>;
constructor(
private voiceRoomStore: IVoiceRoomStore,
private gatewayService: IGatewayService,
private userRepository: IUserRepository,
private liveKitService: ILiveKitService,
private voiceTopology: VoiceTopology,
) {
this.receivers = new Map();
this.serverMap = new Map();
this.rebuildReceivers();
this.voiceTopology.registerSubscriber(() => this.rebuildReceivers());
}
async verifyAndParse(body: string, authHeader: string | undefined): Promise<{event: WebhookEvent; apiKey: string}> {
if (!authHeader) {
throw new Error('Missing authorization header');
}
let lastError: Error | null = null;
for (const [apiKey, receiver] of this.receivers.entries()) {
try {
const event = await receiver.receive(body, authHeader);
return {event: event as WebhookEvent, apiKey};
} catch (error) {
lastError = error as Error;
}
}
throw lastError || new Error('No webhook receivers configured');
}
private rebuildReceivers(): void {
const newReceivers = new Map<string, WebhookReceiver>();
const newServerMap = new Map<string, {regionId: string; serverId: string}>();
const regions = this.voiceTopology.getAllRegions();
for (const region of regions) {
const servers = this.voiceTopology.getServersForRegion(region.id);
for (const server of servers) {
newReceivers.set(server.apiKey, new WebhookReceiver(server.apiKey, server.apiSecret));
newServerMap.set(server.apiKey, {regionId: region.id, serverId: server.serverId});
}
}
this.receivers = newReceivers;
this.serverMap = newServerMap;
}
async handleRoomFinished(event: WebhookEvent): Promise<void> {
if (event.event !== 'room_finished' || !event.room) {
return;
}
const roomName = event.room.name;
const context = parseRoomName(roomName);
if (!context) {
Logger.warn({roomName}, 'Unknown room name format');
return;
}
Logger.debug({roomName, type: context.type}, 'LiveKit room finished, clearing server pinning');
if (isDMRoom(context)) {
await this.voiceRoomStore.deleteRoomServer(undefined, context.channelId);
Logger.debug({channelId: context.channelId.toString()}, 'Cleared DM voice room server pinning');
} else {
await this.voiceRoomStore.deleteRoomServer(context.guildId, context.channelId);
Logger.debug(
{guildId: context.guildId.toString(), channelId: context.channelId.toString()},
'Cleared guild voice room server pinning',
);
try {
const result = await this.gatewayService.disconnectAllVoiceUsersInChannel({
guildId: context.guildId,
channelId: context.channelId,
});
Logger.info(
{
guildId: context.guildId.toString(),
channelId: context.channelId.toString(),
disconnectedCount: result.disconnectedCount,
},
'Cleaned up zombie voice connections for finished room',
);
} catch (error) {
Logger.error(
{error, guildId: context.guildId.toString(), channelId: context.channelId.toString()},
'Failed to clean up voice connections for finished room',
);
}
}
}
async handleParticipantJoined(event: WebhookEvent): Promise<void> {
if (event.event !== 'participant_joined') {
return;
}
const {participant} = event;
if (!participant?.metadata) {
Logger.debug('Participant joined without metadata, skipping');
return;
}
const parsed = parseParticipantMetadataWithRaw(participant.metadata);
if (!parsed) {
Logger.warn({metadata: participant.metadata}, 'Failed to parse participant metadata');
return;
}
const {context} = parsed;
try {
if (context.type === 'dm') {
Logger.info(
{
channelId: context.channelId.toString(),
connectionId: context.connectionId,
participantIdentity: participant.identity,
},
'LiveKit participant_joined - confirming DM call connection',
);
await this.gatewayService.confirmDMCallConnection({
channelId: context.channelId,
connectionId: context.connectionId,
});
} else {
Logger.info(
{
guildId: context.guildId.toString(),
connectionId: context.connectionId,
participantIdentity: participant.identity,
},
'LiveKit participant_joined - confirming guild voice connection',
);
await this.gatewayService.confirmVoiceConnectionFromLiveKit({
guildId: context.guildId,
connectionId: context.connectionId,
});
}
} catch (error) {
Logger.error({error, type: context.type}, 'Error processing participant_joined');
}
}
async handleParticipantLeft(event: WebhookEvent): Promise<void> {
if (event.event !== 'participant_left') {
return;
}
const {participant} = event;
if (!participant?.metadata) {
Logger.debug('Participant left without metadata, skipping');
return;
}
const parsed = parseParticipantMetadataWithRaw(participant.metadata);
if (!parsed) {
Logger.warn({metadata: participant.metadata}, 'Failed to parse participant metadata');
return;
}
const {context} = parsed;
try {
if (context.type === 'dm') {
Logger.info(
{
channelId: context.channelId.toString(),
userId: context.userId.toString(),
connectionId: context.connectionId,
},
'LiveKit participant_left - disconnecting DM call user',
);
await this.gatewayService.disconnectDMCallUserIfInChannel({
channelId: context.channelId,
userId: context.userId,
connectionId: context.connectionId,
});
} else {
Logger.info(
{
guildId: context.guildId.toString(),
userId: context.userId.toString(),
channelId: context.channelId.toString(),
connectionId: context.connectionId,
},
'LiveKit participant_left - disconnecting guild voice user',
);
await this.gatewayService.disconnectVoiceUserIfInChannel({
guildId: context.guildId,
userId: context.userId,
connectionId: context.connectionId,
expectedChannelId: context.channelId,
});
}
} catch (error) {
Logger.error({error, type: context.type}, 'Error processing participant_left');
}
}
async handleTrackPublished(event: WebhookEvent, apiKey: string): Promise<void> {
if (event.event !== 'track_published') {
return;
}
const {room, participant, track} = event;
if (!room || !participant || !track) {
Logger.debug('Track published without required data, skipping');
return;
}
if (track.type !== 1) {
return;
}
try {
const identity = parseParticipantIdentity(participant.identity);
if (!identity) {
Logger.warn({identity: participant.identity}, 'Unexpected participant identity format');
return;
}
const {userId, connectionId} = identity;
const user = await this.userRepository.findUnique(userId);
if (!user) {
Logger.warn({userId: userId.toString()}, 'User not found for track_published event');
return;
}
if (user.isPremium()) {
return;
}
const exceedsResolution = track.width > FREE_MAX_WIDTH || track.height > FREE_MAX_HEIGHT;
if (!exceedsResolution) {
return;
}
Logger.warn(
{userId: userId.toString(), width: track.width, height: track.height},
'Non-premium user attempting to publish video exceeding free tier limits - disconnecting',
);
const roomContext = parseRoomName(room.name);
if (!roomContext) {
Logger.warn({roomName: room.name}, 'Unknown room name format, cannot disconnect');
return;
}
let regionId: string | undefined;
let serverId: string | undefined;
if (participant.metadata) {
const parsed = parseParticipantMetadataWithRaw(participant.metadata);
if (parsed) {
regionId = parsed.raw.region_id;
serverId = parsed.raw.server_id;
}
}
if (!regionId || !serverId) {
const serverInfo = this.serverMap.get(apiKey);
if (serverInfo) {
regionId = serverInfo.regionId;
serverId = serverInfo.serverId;
}
}
if (!regionId || !serverId) {
const guildId = isDMRoom(roomContext) ? undefined : roomContext.guildId;
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(guildId, roomContext.channelId);
if (pinnedServer) {
regionId = pinnedServer.regionId;
serverId = pinnedServer.serverId;
}
}
if (!regionId || !serverId) {
Logger.warn(
{participantId: participant.identity, roomName: room.name, apiKey},
'Missing region or server info, cannot disconnect',
);
return;
}
const guildId = isDMRoom(roomContext) ? undefined : roomContext.guildId;
Logger.info(
{
userId: userId.toString(),
type: roomContext.type,
guildId: guildId?.toString(),
channelId: roomContext.channelId.toString(),
regionId,
serverId,
width: track.width,
height: track.height,
},
'Disconnecting non-premium user for exceeding video quality limits',
);
await this.liveKitService.disconnectParticipant({
userId,
guildId,
channelId: roomContext.channelId,
connectionId,
regionId,
serverId,
});
if (isDMRoom(roomContext)) {
await this.gatewayService.disconnectDMCallUserIfInChannel({
channelId: roomContext.channelId,
userId,
connectionId,
});
} else {
await this.gatewayService.disconnectVoiceUserIfInChannel({
guildId: roomContext.guildId,
userId,
connectionId,
expectedChannelId: roomContext.channelId,
});
}
Logger.info(
{
userId: userId.toString(),
type: roomContext.type,
guildId: guildId?.toString(),
channelId: roomContext.channelId.toString(),
width: track.width,
height: track.height,
},
'Disconnected non-premium user for exceeding video quality limits',
);
} catch (error) {
Logger.error({error}, 'Error processing track_published event');
}
}
async processEvent(data: {event: WebhookEvent; apiKey: string}): Promise<void> {
const {event, apiKey} = data;
switch (event.event) {
case 'participant_joined':
await this.handleParticipantJoined(event);
break;
case 'participant_left':
case 'participant_connection_aborted':
await this.handleParticipantLeft(event);
break;
case 'room_finished':
await this.handleRoomFinished(event);
break;
case 'track_published':
await this.handleTrackPublished(event, apiKey);
break;
default:
Logger.debug({event: event.event}, 'Ignoring LiveKit webhook event');
}
}
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '~/Config';
import {ExplicitContentCannotBeSentError} from '~/Errors';
import {Logger} from '~/Logger';
import * as MediaProxyUtils from '~/utils/MediaProxyUtils';
import {IMediaService, type MediaProxyMetadataRequest, type MediaProxyMetadataResponse} from './IMediaService';
type MediaProxyRequestBody = MediaProxyMetadataRequest | {type: 'upload'; upload_filename: string};
export class MediaService extends IMediaService {
private readonly proxyURL: URL;
constructor() {
super();
this.proxyURL = new URL(Config.endpoints.media);
}
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
const response = await this.makeRequest('/_metadata', request);
if (!response) {
return null;
}
try {
const responseText = await response.text();
if (!responseText) {
Logger.error('Media proxy returned empty response');
return null;
}
const metadata = JSON.parse(responseText) as MediaProxyMetadataResponse;
if (!request.isNSFWAllowed && metadata.nsfw) {
throw new ExplicitContentCannotBeSentError(metadata.nsfw_probability ?? 0, metadata.nsfw_predictions ?? {});
}
return metadata;
} catch (error) {
if (error instanceof ExplicitContentCannotBeSentError) {
throw error;
}
Logger.error({error}, 'Failed to parse media proxy metadata response');
return null;
}
}
getExternalMediaProxyURL(url: string): string {
let urlObj: URL;
try {
urlObj = new URL(url);
} catch (_e) {
return this.handleExternalURL(url);
}
if (urlObj.host === this.proxyURL.host) {
return url;
}
return this.handleExternalURL(url);
}
async getThumbnail(uploadFilename: string): Promise<Buffer | null> {
const response = await this.makeRequest('/_thumbnail', {
type: 'upload',
upload_filename: uploadFilename,
});
if (!response) return null;
try {
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (error) {
Logger.error({error, uploadFilename}, 'Failed to parse media proxy thumbnail response');
return null;
}
}
private async makeRequest(endpoint: string, body: MediaProxyRequestBody): Promise<Response | null> {
try {
const url = `http://${Config.mediaProxy.host}:${Config.mediaProxy.port}${endpoint}`;
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Config.mediaProxy.secretKey}`,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Could not read error body');
Logger.error(
{
status: response.status,
statusText: response.statusText,
errorBody: errorText,
body: this.sanitizeRequestBody(body),
endpoint: url,
},
'Media proxy request failed',
);
return null;
}
return response;
} catch (error) {
Logger.error({error, endpoint}, 'Failed to make media proxy request');
return null;
}
}
private sanitizeRequestBody(body: MediaProxyRequestBody): MediaProxyRequestBody {
if (body?.type === 'base64') {
return {
...body,
base64: '[BASE64_DATA_OMITTED]',
};
}
return body;
}
private handleExternalURL(url: string): string {
return MediaProxyUtils.getExternalMediaProxyURL({
inputURL: url,
mediaProxyEndpoint: Config.endpoints.media,
mediaProxySecretKey: Config.mediaProxy.secretKey,
});
}
}

View File

@@ -0,0 +1,197 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {FLUXER_USER_AGENT} from '~/Constants';
import type {
BatchMetric,
CounterParams,
CrashParams,
GaugeParams,
HistogramParams,
IMetricsService,
} from '~/infrastructure/IMetricsService';
import {Logger} from '~/Logger';
export class MetricsService implements IMetricsService {
private readonly endpoint: string | null;
private readonly enabled: boolean;
constructor(endpoint: string | null) {
this.endpoint = MetricsService.normalizeEndpoint(endpoint);
this.enabled = !!this.endpoint;
if (this.enabled) {
Logger.info({endpoint: this.endpoint}, 'Metrics service initialized');
} else {
Logger.info('Metrics service disabled (FLUXER_METRICS_HOST not set)');
}
}
isEnabled(): boolean {
return this.enabled;
}
counter({name, dimensions, value = 1}: CounterParams): void {
if (!this.enabled) return;
this.fireAndForget(`${this.endpoint}/metrics/counter`, {
name,
dimensions: dimensions ?? {},
value,
});
}
gauge({name, dimensions, value}: GaugeParams): void {
if (!this.enabled) return;
this.fireAndForget(`${this.endpoint}/metrics/gauge`, {
name,
dimensions: dimensions ?? {},
value,
});
}
histogram({name, dimensions, valueMs}: HistogramParams): void {
if (!this.enabled) return;
this.fireAndForget(`${this.endpoint}/metrics/histogram`, {
name,
dimensions: dimensions ?? {},
value_ms: valueMs,
});
}
crash({guildId, stacktrace}: CrashParams): void {
if (!this.enabled) return;
this.fireAndForget(`${this.endpoint}/metrics/crash`, {
guild_id: guildId,
stacktrace,
});
}
batch(metrics: Array<BatchMetric>): void {
if (!this.enabled || metrics.length === 0) return;
const payload = metrics.map((m) => {
if (m.type === 'counter') {
return {
type: 'counter',
name: m.name,
dimensions: m.dimensions ?? {},
value: m.value ?? 1,
};
}
if (m.type === 'gauge') {
return {
type: 'gauge',
name: m.name,
dimensions: m.dimensions ?? {},
value: m.value ?? 0,
};
}
return {
type: 'histogram',
name: m.name,
dimensions: m.dimensions ?? {},
value_ms: m.valueMs ?? 0,
};
});
this.fireAndForget(`${this.endpoint}/metrics/batch`, {metrics: payload});
}
private fireAndForget(url: string, body: unknown): void {
const jsonBody = JSON.stringify(body);
this.sendMetricWithRetry(url, jsonBody, 0).catch(() => {});
}
private async sendMetricWithRetry(url: string, body: string, attempt: number): Promise<void> {
const MAX_RETRIES = 1;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': FLUXER_USER_AGENT,
},
body,
signal: AbortSignal.timeout(5000),
});
if (!response.ok && attempt < MAX_RETRIES) {
await this.sendMetricWithRetry(url, body, attempt + 1);
} else if (!response.ok) {
Logger.warn({url, status: response.status, attempts: attempt + 1}, 'Failed to send metric after retries');
}
} catch (error) {
if (attempt < MAX_RETRIES) {
await this.sendMetricWithRetry(url, body, attempt + 1);
} else {
Logger.warn({error, url, attempts: attempt + 1}, 'Failed to send metric after retries');
}
}
}
private static normalizeEndpoint(endpoint: string | null): string | null {
if (!endpoint) {
return null;
}
const trimmed = endpoint.trim().replace(/\/$/, '');
if (trimmed === '') {
return null;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) {
return trimmed;
}
return `http://${trimmed}`;
}
}
class NoopMetricsService implements IMetricsService {
isEnabled(): boolean {
return false;
}
counter(_params: CounterParams): void {}
gauge(_params: GaugeParams): void {}
histogram(_params: HistogramParams): void {}
crash(_params: CrashParams): void {}
batch(_metrics: Array<BatchMetric>): void {}
}
let metricsServiceInstance: IMetricsService | null = null;
export function initializeMetricsService(endpoint: string | null): IMetricsService {
if (metricsServiceInstance) {
return metricsServiceInstance;
}
if (endpoint) {
metricsServiceInstance = new MetricsService(endpoint);
} else {
metricsServiceInstance = new NoopMetricsService();
}
return metricsServiceInstance;
}
export function getMetricsService(): IMetricsService {
if (!metricsServiceInstance) {
metricsServiceInstance = new NoopMetricsService();
}
return metricsServiceInstance;
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '~/BrandedTypes';
import type {ICacheService} from '~/infrastructure/ICacheService';
const PENDING_INVITE_TTL_SECONDS = 90 * 24 * 60 * 60;
const PENDING_INVITE_KEY_PREFIX = 'pending-join-invite';
export class PendingJoinInviteStore {
constructor(private readonly cacheService: ICacheService) {}
private getKey(userId: UserID): string {
return `${PENDING_INVITE_KEY_PREFIX}:${userId}`;
}
async setPendingInvite(userId: UserID, inviteCode: string): Promise<void> {
await this.cacheService.set(this.getKey(userId), inviteCode, PENDING_INVITE_TTL_SECONDS);
}
async getPendingInvite(userId: UserID): Promise<string | null> {
return this.cacheService.get<string>(this.getKey(userId));
}
async deletePendingInvite(userId: UserID): Promise<void> {
await this.cacheService.delete(this.getKey(userId));
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ICacheService} from './ICacheService';
import type {BucketConfig, IRateLimitService, RateLimitConfig, RateLimitResult} from './IRateLimitService';
interface RateLimitData {
attempts: number;
resetTime: Date;
}
export class RateLimitService implements IRateLimitService {
private static readonly GLOBAL_WINDOW_MS = 1000;
constructor(private cacheService: ICacheService) {}
private async getRateLimitData(key: string): Promise<RateLimitData | null> {
const rawData = await this.cacheService.get<RateLimitData>(key);
if (!rawData) return null;
return {
...rawData,
resetTime: rawData.resetTime instanceof Date ? rawData.resetTime : new Date(rawData.resetTime),
};
}
private async checkLimitInternal(
key: string,
limit: number,
windowMs: number,
global?: boolean,
): Promise<RateLimitResult> {
const now = new Date();
const data = await this.getRateLimitData(key);
if (!data || now >= data.resetTime) {
const resetTime = new Date(now.getTime() + windowMs);
const newData: RateLimitData = {
attempts: 1,
resetTime,
};
await this.cacheService.set(key, newData, Math.ceil(windowMs / 1000));
return {
allowed: true,
limit,
remaining: limit - 1,
resetTime,
global,
};
}
if (data.attempts >= limit) {
const retryAfterDecimal = (data.resetTime.getTime() - now.getTime()) / 1000;
const retryAfter = Math.ceil(retryAfterDecimal);
return {
allowed: false,
limit,
remaining: 0,
resetTime: data.resetTime,
retryAfter,
retryAfterDecimal,
global,
};
}
const updatedData: RateLimitData = {
...data,
attempts: data.attempts + 1,
};
const ttl = Math.ceil((data.resetTime.getTime() - now.getTime()) / 1000);
await this.cacheService.set(key, updatedData, ttl);
return {
allowed: true,
limit,
remaining: limit - updatedData.attempts,
resetTime: data.resetTime,
global,
};
}
async checkLimit(config: RateLimitConfig): Promise<RateLimitResult> {
const key = `ratelimit:${config.identifier}`;
return this.checkLimitInternal(key, config.maxAttempts, config.windowMs);
}
async checkBucketLimit(bucket: string, config: BucketConfig): Promise<RateLimitResult> {
const key = `ratelimit:bucket:${bucket}`;
return this.checkLimitInternal(key, config.limit, config.windowMs);
}
async checkGlobalLimit(identifier: string, limit: number): Promise<RateLimitResult> {
const key = `ratelimit:global:${identifier}`;
return this.checkLimitInternal(key, limit, RateLimitService.GLOBAL_WINDOW_MS, true);
}
async resetLimit(identifier: string): Promise<void> {
const key = `ratelimit:${identifier}`;
await this.cacheService.delete(key);
}
async getRemainingAttempts(identifier: string, _windowMs: number): Promise<number> {
const key = `ratelimit:${identifier}`;
const data = await this.getRateLimitData(key);
const now = new Date();
if (!data || now >= data.resetTime) {
return 0;
}
return Math.max(0, data.attempts);
}
async getResetTime(identifier: string, _windowMs: number): Promise<Date> {
const key = `ratelimit:${identifier}`;
const data = await this.getRateLimitData(key);
const now = new Date();
if (!data || now >= data.resetTime) {
return now;
}
return data.resetTime;
}
}

View File

@@ -0,0 +1,272 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Redis} from 'ioredis';
import type {UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import type {UserRepository} from '~/user/UserRepository';
interface QueuedDeletion {
userId: bigint;
deletionReasonCode: number;
}
const QUEUE_KEY = 'deletion_queue';
const STATE_VERSION_KEY = 'deletion_queue:state_version';
const REBUILD_LOCK_KEY = 'deletion_queue:rebuild_lock';
const REBUILD_LOCK_TTL = 300;
const STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export class RedisAccountDeletionQueueService {
constructor(
private readonly redis: Redis,
private readonly userRepository: UserRepository,
) {}
private serializeQueueItem(item: QueuedDeletion): string {
return `${item.userId}|${item.deletionReasonCode}`;
}
private deserializeQueueItem(value: string): QueuedDeletion {
const parts = value.split('|');
return {
userId: BigInt(parts[0]),
deletionReasonCode: parseInt(parts[1], 10),
};
}
async needsRebuild(): Promise<boolean> {
try {
const versionExists = await this.redis.exists(STATE_VERSION_KEY);
if (!versionExists) {
Logger.debug('Deletion queue needs rebuild: no state version');
return true;
}
const stateVersionStr = await this.redis.get(STATE_VERSION_KEY);
if (stateVersionStr) {
const stateVersion = parseInt(stateVersionStr, 10);
const ageMs = Date.now() - stateVersion;
if (ageMs > STATE_MAX_AGE_MS) {
Logger.debug({ageMs, maxAgeMs: STATE_MAX_AGE_MS}, 'Deletion queue needs rebuild: state too old');
return true;
}
}
return false;
} catch (error) {
Logger.error({error}, 'Failed to check if deletion queue needs rebuild');
throw error;
}
}
async rebuildState(): Promise<void> {
Logger.info('Starting deletion queue rebuild from Cassandra');
try {
const pipeline = this.redis.pipeline();
pipeline.del(QUEUE_KEY);
pipeline.del(STATE_VERSION_KEY);
await pipeline.exec();
let lastUserId: UserID | undefined;
let totalProcessed = 0;
let totalQueued = 0;
const batchSize = 1000;
while (true) {
const users = await this.userRepository.listAllUsersPaginated(batchSize, lastUserId);
if (users.length === 0) {
break;
}
const queuePipeline = this.redis.pipeline();
let batchQueued = 0;
for (const user of users) {
if (user.pendingDeletionAt) {
const queueItem: QueuedDeletion = {
userId: user.id,
deletionReasonCode: user.deletionReasonCode ?? 0,
};
const score = user.pendingDeletionAt.getTime();
const value = this.serializeQueueItem(queueItem);
const secondaryKey = this.getSecondaryKey(user.id);
queuePipeline.zadd(QUEUE_KEY, score, value);
queuePipeline.set(secondaryKey, value);
batchQueued++;
}
}
if (batchQueued > 0) {
await queuePipeline.exec();
totalQueued += batchQueued;
}
totalProcessed += users.length;
lastUserId = users[users.length - 1].id;
if (totalProcessed % 10000 === 0) {
Logger.debug({totalProcessed, totalQueued}, 'Deletion queue rebuild progress');
}
}
await this.redis.set(STATE_VERSION_KEY, Date.now().toString());
Logger.info({totalProcessed, totalQueued}, 'Deletion queue rebuild completed');
} catch (error) {
Logger.error({error}, 'Failed to rebuild deletion queue state');
throw error;
}
}
async scheduleDeletion(userId: UserID, pendingAt: Date, reasonCode: number): Promise<void> {
try {
const queueItem: QueuedDeletion = {
userId,
deletionReasonCode: reasonCode,
};
const score = pendingAt.getTime();
const value = this.serializeQueueItem(queueItem);
const secondaryKey = this.getSecondaryKey(userId);
const pipeline = this.redis.pipeline();
pipeline.zadd(QUEUE_KEY, score, value);
pipeline.set(secondaryKey, value);
await pipeline.exec();
Logger.debug({userId: userId.toString(), pendingAt, reasonCode}, 'Scheduled user deletion');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to schedule deletion');
throw error;
}
}
async removeFromQueue(userId: UserID): Promise<void> {
try {
const secondaryKey = this.getSecondaryKey(userId);
const value = await this.redis.get(secondaryKey);
if (!value) {
Logger.debug({userId: userId.toString()}, 'User not in deletion queue');
return;
}
const pipeline = this.redis.pipeline();
pipeline.zrem(QUEUE_KEY, value);
pipeline.del(secondaryKey);
await pipeline.exec();
Logger.debug({userId: userId.toString()}, 'Removed user from deletion queue');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to remove user from deletion queue');
throw error;
}
}
async getReadyDeletions(nowMs: number, limit: number): Promise<Array<QueuedDeletion>> {
try {
const results = await this.redis.zrangebyscore(QUEUE_KEY, '-inf', nowMs, 'LIMIT', 0, limit);
const deletions: Array<QueuedDeletion> = [];
for (const result of results) {
try {
const deletion = this.deserializeQueueItem(result);
deletions.push(deletion);
} catch (parseError) {
Logger.error({error: parseError, result}, 'Failed to parse queued deletion');
}
}
return deletions;
} catch (error) {
Logger.error({error, nowMs, limit}, 'Failed to get ready deletions');
throw error;
}
}
async acquireRebuildLock(): Promise<string | null> {
try {
const token = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const result = await this.redis.set(REBUILD_LOCK_KEY, token, 'EX', REBUILD_LOCK_TTL, 'NX');
if (result === 'OK') {
Logger.debug({token}, 'Acquired rebuild lock');
return token;
}
return null;
} catch (error) {
Logger.error({error}, 'Failed to acquire rebuild lock');
throw error;
}
}
async releaseRebuildLock(token: string): Promise<boolean> {
try {
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const result = (await this.redis.eval(luaScript, 1, REBUILD_LOCK_KEY, token)) as number;
const released = result === 1;
if (released) {
Logger.debug({token}, 'Released rebuild lock');
}
return released;
} catch (error) {
Logger.error({error, token}, 'Failed to release rebuild lock');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.redis.zcard(QUEUE_KEY);
} catch (error) {
Logger.error({error}, 'Failed to get queue size');
throw error;
}
}
async getStateVersion(): Promise<number | null> {
try {
const versionStr = await this.redis.get(STATE_VERSION_KEY);
return versionStr ? parseInt(versionStr, 10) : null;
} catch (error) {
Logger.error({error}, 'Failed to get state version');
throw error;
}
}
private getSecondaryKey(userId: UserID): string {
return `deletion_queue_by_user:${userId.toString()}`;
}
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Redis} from 'ioredis';
import type {UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import {UserRepository} from '~/user/UserRepository';
const TTL_SECONDS = 90 * 24 * 60 * 60;
const STATE_VERSION_KEY = 'activity_tracker:state_version';
const STATE_VERSION_TTL_SECONDS = 24 * 60 * 60;
const REBUILD_BATCH_SIZE = 100;
export class RedisActivityTracker {
private redis: Redis;
constructor(redis: Redis) {
this.redis = redis;
}
private getActivityKey(userId: UserID): string {
return `user_activity:${userId}`;
}
async updateActivity(userId: UserID, timestamp: Date): Promise<void> {
const key = this.getActivityKey(userId);
const value = timestamp.getTime().toString();
await this.redis.setex(key, TTL_SECONDS, value);
}
async getActivity(userId: UserID): Promise<Date | null> {
const key = this.getActivityKey(userId);
const value = await this.redis.get(key);
if (!value) {
return null;
}
const timestamp = parseInt(value, 10);
if (Number.isNaN(timestamp)) {
return null;
}
return new Date(timestamp);
}
async needsRebuild(): Promise<boolean> {
const exists = await this.redis.exists(STATE_VERSION_KEY);
if (exists === 0) {
return true;
}
const ttl = await this.redis.ttl(STATE_VERSION_KEY);
if (ttl < 0) {
return true;
}
const age = STATE_VERSION_TTL_SECONDS - ttl;
return age > STATE_VERSION_TTL_SECONDS;
}
async rebuildActivities(): Promise<void> {
Logger.info('Starting activity tracker rebuild from Cassandra');
const userRepository = new UserRepository();
try {
const redisBatchSize = 1000;
let processedCount = 0;
let usersWithActivity = 0;
let pipeline = this.redis.pipeline();
let pipelineCount = 0;
let lastUserId: UserID | undefined;
while (true) {
const users = await userRepository.listAllUsersPaginated(REBUILD_BATCH_SIZE, lastUserId);
if (users.length === 0) {
break;
}
for (const user of users) {
if (user.lastActiveAt) {
const key = this.getActivityKey(user.id);
const value = user.lastActiveAt.getTime().toString();
pipeline.setex(key, TTL_SECONDS, value);
pipelineCount++;
usersWithActivity++;
if (pipelineCount >= redisBatchSize) {
await pipeline.exec();
pipeline = this.redis.pipeline();
pipelineCount = 0;
}
}
processedCount++;
}
if (processedCount % 10000 === 0) {
Logger.info({processedCount, usersWithActivity}, 'Activity tracker rebuild progress');
}
lastUserId = users[users.length - 1].id;
}
if (pipelineCount > 0) {
await pipeline.exec();
}
await this.redis.setex(STATE_VERSION_KEY, STATE_VERSION_TTL_SECONDS, Date.now().toString());
Logger.info({processedCount, usersWithActivity}, 'Activity tracker rebuild completed');
} catch (error) {
Logger.error({error}, 'Activity tracker rebuild failed');
throw error;
}
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Redis} from 'ioredis';
import type {UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
interface QueuedBulkMessageDeletion {
userId: bigint;
scheduledAt: number;
}
const QUEUE_KEY = 'bulk_message_deletion_queue';
const SECONDARY_KEY_PREFIX = 'bulk_message_deletion_queue:';
export class RedisBulkMessageDeletionQueueService {
constructor(private readonly redis: Redis) {}
private getSecondaryKey(userId: UserID): string {
return `${SECONDARY_KEY_PREFIX}${userId}`;
}
private serializeQueueItem(item: QueuedBulkMessageDeletion): string {
return `${item.userId}|${item.scheduledAt}`;
}
private deserializeQueueItem(value: string): QueuedBulkMessageDeletion {
const [userIdStr, scheduledAtStr] = value.split('|');
return {
userId: BigInt(userIdStr),
scheduledAt: Number.parseInt(scheduledAtStr, 10),
};
}
async scheduleDeletion(userId: UserID, scheduledAt: Date): Promise<void> {
try {
const entry: QueuedBulkMessageDeletion = {
userId,
scheduledAt: scheduledAt.getTime(),
};
const value = this.serializeQueueItem(entry);
const secondaryKey = this.getSecondaryKey(userId);
await this.redis.pipeline().zadd(QUEUE_KEY, entry.scheduledAt, value).set(secondaryKey, value).exec();
Logger.debug({userId: userId.toString(), scheduledAt}, 'Scheduled bulk message deletion');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to schedule bulk message deletion');
throw error;
}
}
async removeFromQueue(userId: UserID): Promise<void> {
try {
const secondaryKey = this.getSecondaryKey(userId);
const value = await this.redis.get(secondaryKey);
if (!value) {
Logger.debug({userId: userId.toString()}, 'User not in bulk message deletion queue');
return;
}
await this.redis.pipeline().zrem(QUEUE_KEY, value).del(secondaryKey).exec();
Logger.debug({userId: userId.toString()}, 'Removed bulk message deletion from queue');
} catch (error) {
Logger.error({error, userId: userId.toString()}, 'Failed to remove bulk message deletion from queue');
throw error;
}
}
async getReadyDeletions(nowMs: number, limit: number): Promise<Array<QueuedBulkMessageDeletion>> {
try {
const results = await this.redis.zrangebyscore(QUEUE_KEY, '-inf', nowMs, 'LIMIT', 0, limit);
const deletions: Array<QueuedBulkMessageDeletion> = [];
for (const result of results) {
try {
const deletion = this.deserializeQueueItem(result);
deletions.push(deletion);
} catch (error) {
Logger.error({error, result}, 'Failed to parse queued bulk message deletion entry');
}
}
return deletions;
} catch (error) {
Logger.error({error, nowMs, limit}, 'Failed to fetch ready bulk message deletions');
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
return await this.redis.zcard(QUEUE_KEY);
} catch (error) {
Logger.error({error}, 'Failed to get bulk message deletion queue size');
throw error;
}
}
}

View File

@@ -0,0 +1,196 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Redis} from 'ioredis';
import {ICacheService} from './ICacheService';
export class RedisCacheService extends ICacheService {
private redis: Redis;
constructor(redis: Redis) {
super();
this.redis = redis;
}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (value == null) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const serializedValue = JSON.stringify(value);
if (ttlSeconds) {
await this.redis.setex(key, ttlSeconds, serializedValue);
} else {
await this.redis.set(key, serializedValue);
}
}
async delete(key: string): Promise<void> {
await this.redis.del(key);
}
async getAndDelete<T>(key: string): Promise<T | null> {
const pipeline = this.redis.multi();
pipeline.get(key);
pipeline.del(key);
const results = await pipeline.exec();
if (!results || results.length === 0) {
return null;
}
const [getResult] = results;
if (!getResult || getResult[1] == null) {
return null;
}
try {
return JSON.parse(getResult[1] as string);
} catch {
return null;
}
}
async exists(key: string): Promise<boolean> {
const result = await this.redis.exists(key);
return result === 1;
}
async expire(key: string, ttlSeconds: number): Promise<void> {
await this.redis.expire(key, ttlSeconds);
}
async ttl(key: string): Promise<number> {
return await this.redis.ttl(key);
}
async mget<T>(keys: Array<string>): Promise<Array<T | null>> {
if (keys.length === 0) return [];
const values = await this.redis.mget(...keys);
return values.map((value) => {
if (value == null) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
});
}
async mset<T>(entries: Array<{key: string; value: T; ttlSeconds?: number}>): Promise<void> {
if (entries.length === 0) return;
const withoutTtl: Array<{key: string; value: T}> = [];
const withTtl: Array<{key: string; value: T; ttlSeconds: number}> = [];
for (const entry of entries) {
if (entry.ttlSeconds) {
withTtl.push({
key: entry.key,
value: entry.value,
ttlSeconds: entry.ttlSeconds,
});
} else {
withoutTtl.push({
key: entry.key,
value: entry.value,
});
}
}
const pipeline = this.redis.pipeline();
if (withoutTtl.length > 0) {
const flatArgs: Array<string> = [];
for (const entry of withoutTtl) {
flatArgs.push(entry.key, JSON.stringify(entry.value));
}
pipeline.mset(...flatArgs);
}
for (const entry of withTtl) {
pipeline.setex(entry.key, entry.ttlSeconds, JSON.stringify(entry.value));
}
await pipeline.exec();
}
async deletePattern(pattern: string): Promise<number> {
const redisPattern = pattern.replace(/\*/g, '*');
const keys = await this.redis.keys(redisPattern);
if (keys.length === 0) return 0;
await this.redis.del(...keys);
return keys.length;
}
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
const token = Math.random().toString(36).substring(2, 15);
const lockKey = `lock:${key}`;
const result = await this.redis.set(lockKey, token, 'EX', ttlSeconds, 'NX');
return result === 'OK' ? token : null;
}
async releaseLock(key: string, token: string): Promise<boolean> {
const lockKey = `lock:${key}`;
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const result = (await this.redis.eval(luaScript, 1, lockKey, token)) as number;
return result === 1;
}
async getAndRenewTtl<T>(key: string, newTtlSeconds: number): Promise<T | null> {
const pipeline = this.redis.pipeline();
pipeline.get(key);
pipeline.expire(key, newTtlSeconds);
const results = await pipeline.exec();
if (!results) return null;
const [getResult] = results;
if (!getResult || getResult[1] == null) return null;
try {
return JSON.parse(getResult[1] as string);
} catch {
return null;
}
}
async publish(channel: string, message: string): Promise<void> {
await this.redis.publish(channel, message);
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import twilio from 'twilio';
import {Config} from '~/Config';
import {InvalidPhoneNumberError} from '~/Errors';
import type {ISMSService} from '~/infrastructure/ISMSService';
import {Logger} from '~/Logger';
const TWILIO_INVALID_PHONE_ERROR_CODE = 21211;
interface TwilioErrorLike {
code?: number;
status?: number;
message?: string;
}
const isInvalidTwilioPhoneError = (error: unknown): error is TwilioErrorLike => {
if (typeof error !== 'object' || error === null) {
return false;
}
return (error as TwilioErrorLike).code === TWILIO_INVALID_PHONE_ERROR_CODE;
};
export class SMSService implements ISMSService {
private twilioClient: ReturnType<typeof twilio> | null = null;
constructor() {
if (Config.sms.enabled && Config.sms.accountSid && Config.sms.authToken && Config.sms.verifyServiceSid) {
this.twilioClient = twilio(Config.sms.accountSid, Config.sms.authToken);
}
}
async startVerification(phone: string): Promise<void> {
if (!Config.sms.enabled || !Config.sms.verifyServiceSid) {
return;
}
if (!this.twilioClient) {
Logger.error('[SMSService] Twilio client not initialized');
throw new Error('Twilio Verify service not properly configured');
}
try {
await this.twilioClient.verify.v2
.services(Config.sms.verifyServiceSid)
.verifications.create({to: phone, channel: 'sms'});
} catch (error) {
if (isInvalidTwilioPhoneError(error)) {
Logger.warn({error}, `[SMSService] Twilio rejected phone ${phone.slice(0, 6)}*** as invalid`);
throw new InvalidPhoneNumberError();
}
Logger.error({error}, '[SMSService] Failed to start verification via Twilio Verify');
throw error;
}
}
async checkVerification(phone: string, code: string): Promise<boolean> {
if (!Config.sms.enabled || !Config.sms.verifyServiceSid) {
return true;
}
if (!this.twilioClient) {
Logger.error('[SMSService] Twilio client not initialized');
throw new Error('Twilio Verify service not properly configured');
}
try {
const verificationCheck = await this.twilioClient.verify.v2
.services(Config.sms.verifyServiceSid)
.verificationChecks.create({to: phone, code});
return verificationCheck.status === 'approved';
} catch (error) {
Logger.error({error}, '[SMSService] Failed to check verification via Twilio Verify');
return false;
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import type {Redis} from 'ioredis';
import {FLUXER_EPOCH} from '~/Constants';
const MAX_SEQ = 4095;
const MAX_NODE_ID = 1023;
const TIMESTAMP_SHIFT = 22n;
const NODE_ID_SHIFT = 12n;
const NODE_ID_TTL = 300;
const NODE_ID_RENEWAL_INTERVAL = 240;
class SnowflakeService {
private epoch: bigint;
private nodeId: number | null = null;
private seq: number;
private lastSeqExhaustion: bigint;
private redis: Redis | null = null;
private instanceId: string;
private renewalInterval: NodeJS.Timeout | null = null;
private initializationPromise: Promise<void> | null = null;
constructor(redis?: Redis) {
this.epoch = BigInt(FLUXER_EPOCH);
this.seq = 0;
this.lastSeqExhaustion = 0n;
this.instanceId = randomUUID();
if (redis) {
this.redis = redis;
}
}
async initialize(): Promise<void> {
if (this.nodeId != null) {
return;
}
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
if (this.redis) {
this.nodeId = await this.acquireNodeId();
this.startNodeIdRenewal();
} else {
this.nodeId = 0;
}
})().finally(() => {
this.initializationPromise = null;
});
}
await this.initializationPromise;
}
private async acquireNodeId(): Promise<number> {
if (!this.redis) {
throw new Error('Redis not available for node ID allocation');
}
const nodeIdKey = 'snowflake:node_counter';
const nodeRegistryKey = 'snowflake:nodes';
for (let attempt = 0; attempt < MAX_NODE_ID; attempt++) {
const candidateId = await this.redis.incr(nodeIdKey);
const normalizedId = (candidateId - 1) % (MAX_NODE_ID + 1);
const lockKey = `snowflake:node:${normalizedId}`;
const acquired = await this.redis.set(lockKey, this.instanceId, 'EX', NODE_ID_TTL, 'NX');
if (acquired === 'OK') {
await this.redis.hset(nodeRegistryKey, normalizedId, this.instanceId);
return normalizedId;
}
}
throw new Error('Unable to acquire unique node ID - all nodes in use');
}
private startNodeIdRenewal(): void {
if (this.renewalInterval) {
return;
}
this.renewalInterval = setInterval(async () => {
await this.renewNodeId();
}, NODE_ID_RENEWAL_INTERVAL * 1000);
}
private async renewNodeId(): Promise<void> {
if (!this.redis || this.nodeId == null) return;
const lockKey = `snowflake:node:${this.nodeId}`;
await this.redis.expire(lockKey, NODE_ID_TTL);
}
async shutdown(): Promise<void> {
if (this.renewalInterval) {
clearInterval(this.renewalInterval);
this.renewalInterval = null;
}
const nodeId = this.nodeId;
if (this.redis && nodeId != null) {
const lockKey = `snowflake:node:${nodeId}`;
const nodeRegistryKey = 'snowflake:nodes';
this.nodeId = null;
try {
await this.redis.del(lockKey);
await this.redis.hdel(nodeRegistryKey, nodeId.toString());
} catch (_err) {}
} else {
this.nodeId = null;
}
}
public generate(): bigint {
if (this.nodeId == null) {
throw new Error('SnowflakeService not initialized - call initialize() first');
}
const currentTime = BigInt(Date.now());
return this.generateWithTimestamp(currentTime);
}
private generateWithTimestamp(timestamp: bigint): bigint {
if (this.nodeId == null) {
throw new Error('SnowflakeService not initialized - call initialize() first');
}
while (this.seq === 0 && timestamp <= this.lastSeqExhaustion) {
this.sleep(1);
timestamp = BigInt(Date.now());
}
const epochDiff = timestamp - this.epoch;
const snowflakeId = (epochDiff << TIMESTAMP_SHIFT) | (BigInt(this.nodeId) << NODE_ID_SHIFT) | BigInt(this.seq);
if (this.seq >= MAX_SEQ) {
this.seq = 0;
this.lastSeqExhaustion = timestamp;
} else {
this.seq += 1;
}
return snowflakeId;
}
private sleep(milliseconds: number): void {
const start = Date.now();
while (Date.now() - start < milliseconds) {}
}
}
export {SnowflakeService};

View File

@@ -0,0 +1,470 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import assert from 'node:assert/strict';
import {execFile} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {PassThrough, pipeline, Readable} from 'node:stream';
import {promisify} from 'node:util';
import {
CopyObjectCommand,
CreateBucketCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
type GetObjectCommandOutput,
HeadBucketCommand,
HeadObjectCommand,
type HeadObjectCommandOutput,
ListObjectsV2Command,
type ListObjectsV2CommandOutput,
PutBucketPolicyCommand,
PutObjectCommand,
S3Client,
S3ServiceException,
} from '@aws-sdk/client-s3';
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
import sharp from 'sharp';
import {temporaryFile} from 'tempy';
import {Config} from '~/Config';
import type {IStorageService} from '~/infrastructure/IStorageService';
import {Logger} from '~/Logger';
const pipelinePromise = promisify(pipeline);
const execFilePromise = promisify(execFile);
export class StorageService implements IStorageService {
private readonly s3: S3Client;
private readonly presignClient: S3Client;
constructor() {
const baseInit = {
endpoint: Config.s3.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: Config.s3.accessKeyId,
secretAccessKey: Config.s3.secretAccessKey,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
} as const;
this.s3 = new S3Client({...baseInit, forcePathStyle: true});
this.presignClient = new S3Client({...baseInit, forcePathStyle: false});
}
private getClient(_bucket: string): S3Client {
return this.s3;
}
async uploadObject({
bucket,
key,
body,
contentType,
expiresAt,
}: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
expiresAt?: Date;
}): Promise<void> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
Expires: expiresAt,
});
await this.getClient(bucket).send(command);
}
async getPresignedDownloadURL({
bucket,
key,
expiresIn = 300,
}: {
bucket: string;
key: string;
expiresIn?: number;
}): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
return getSignedUrl(this.presignClient, command, {expiresIn});
}
async deleteObject(bucket: string, key: string): Promise<void> {
const command = new DeleteObjectCommand({Bucket: bucket, Key: key});
await this.getClient(bucket).send(command);
}
async getObjectMetadata(bucket: string, key: string): Promise<{contentLength: number; contentType: string} | null> {
try {
const command = new HeadObjectCommand({Bucket: bucket, Key: key});
const response = await this.getClient(bucket).send(command);
return {
contentLength: response.ContentLength ?? 0,
contentType: response.ContentType ?? '',
};
} catch (error) {
if (error instanceof S3ServiceException && error.name === 'NotFound') {
return null;
}
throw error;
}
}
private async streamToBuffer(stream: Readable): Promise<Uint8Array> {
const chunks: Array<Uint8Array> = [];
for await (const chunk of stream) {
chunks.push(new Uint8Array(Buffer.from(chunk)));
}
return new Uint8Array(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
}
async readObject(bucket: string, key: string): Promise<Uint8Array> {
const command = new GetObjectCommand({Bucket: bucket, Key: key});
const {Body} = await this.getClient(bucket).send(command);
assert(Body != null && Body instanceof Readable);
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
return this.streamToBuffer(stream);
}
async streamObject(params: {bucket: string; key: string; range?: string}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
const command = new GetObjectCommand({
Bucket: params.bucket,
Key: params.key,
Range: params.range,
});
const response = await this.getClient(params.bucket).send(command);
assert(response.Body != null && response.Body instanceof Readable);
const stream = response.Body instanceof PassThrough ? response.Body : response.Body.pipe(new PassThrough());
return {
body: stream,
contentLength: response.ContentLength ?? 0,
contentRange: response.ContentRange ?? null,
contentType: response.ContentType ?? null,
cacheControl: response.CacheControl ?? null,
contentDisposition: response.ContentDisposition ?? null,
expires: response.Expires ?? null,
etag: response.ETag ?? null,
lastModified: response.LastModified ?? null,
};
}
private async ensureDirectoryExists(dirPath: string): Promise<void> {
await fs.promises.mkdir(dirPath, {recursive: true});
}
async writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void> {
await this.ensureDirectoryExists(path.dirname(filePath));
const command = new GetObjectCommand({Bucket: bucket, Key: key});
const {Body} = await this.getClient(bucket).send(command);
assert(Body != null && Body instanceof Readable);
const stream = Body instanceof PassThrough ? Body : Body.pipe(new PassThrough());
const writeStream = fs.createWriteStream(filePath);
await pipelinePromise(stream, writeStream);
}
async copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
const command = new CopyObjectCommand({
Bucket: destinationBucket,
Key: destinationKey,
CopySource: `${sourceBucket}/${sourceKey}`,
ContentType: newContentType,
MetadataDirective: newContentType ? 'REPLACE' : undefined,
});
await this.getClient(destinationBucket).send(command);
}
async copyObjectWithJpegProcessing({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
contentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null> {
const isJpeg = contentType.toLowerCase().includes('jpeg') || contentType.toLowerCase().includes('jpg');
if (!isJpeg) {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
try {
const sourceData = await this.readObject(sourceBucket, sourceKey);
return await this.processAndUploadJpeg({
sourceData,
destinationBucket,
destinationKey,
contentType,
});
} catch (error) {
Logger.error({error}, 'Failed to process JPEG, falling back to simple copy');
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType: contentType,
});
return null;
}
}
private async processAndUploadJpeg({
sourceData,
destinationBucket,
destinationKey,
contentType,
}: {
sourceData: Uint8Array;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null> {
const inputPath = temporaryFile({extension: 'jpg'});
const outputPath = temporaryFile({extension: 'jpg'});
try {
await fs.promises.writeFile(inputPath, sourceData);
const orientation = await this.getJpegOrientation(inputPath);
const image = sharp(sourceData);
const metadata = await image.metadata();
const processedBuffer = await image
.rotate(orientation === 6 ? 90 : 0)
.jpeg({
quality: 100,
chromaSubsampling: '4:2:0',
})
.toBuffer();
await fs.promises.writeFile(outputPath, processedBuffer);
await this.stripJpegMetadata(outputPath);
const finalBuffer = await fs.promises.readFile(outputPath);
await this.uploadObject({
bucket: destinationBucket,
key: destinationKey,
body: finalBuffer,
contentType,
});
if (metadata.width && metadata.height) {
return orientation === 6
? {width: metadata.height, height: metadata.width}
: {width: metadata.width, height: metadata.height};
}
return null;
} finally {
await Promise.all([
fs.promises.unlink(inputPath).catch(() => {}),
fs.promises.unlink(outputPath).catch(() => {}),
]);
}
}
private async getJpegOrientation(filePath: string): Promise<number> {
const {stdout} = await execFilePromise('exiftool', ['-Orientation#', '-n', '-j', filePath]);
const [{Orientation = 1}] = JSON.parse(stdout);
return Orientation;
}
private async stripJpegMetadata(filePath: string): Promise<void> {
await execFilePromise('exiftool', [
'-all=',
'-jfif:all=',
'-JFIFVersion=1.01',
'-ResolutionUnit=none',
'-XResolution=1',
'-YResolution=1',
'-n',
'-overwrite_original',
'-F',
'-exif:all=',
'-iptc:all=',
'-xmp:all=',
'-icc_profile:all=',
'-photoshop:all=',
'-adobe:all=',
filePath,
]);
}
async moveObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
}: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
await this.copyObject({
sourceBucket,
sourceKey,
destinationBucket,
destinationKey,
newContentType,
});
await this.deleteObject(sourceBucket, sourceKey);
}
async createBucket(bucket: string, allowPublicAccess = false): Promise<void> {
try {
await this.s3.send(new HeadBucketCommand({Bucket: bucket}));
} catch (error) {
if (error instanceof S3ServiceException && error.name === 'NotFound') {
Logger.debug({bucket}, 'Creating bucket');
await this.s3.send(new CreateBucketCommand({Bucket: bucket}));
if (allowPublicAccess) {
const policy = {
Version: '2012-10-17',
Statement: [
{
Sid: 'PublicReadGetObject',
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: `arn:aws:s3:::${bucket}/*`,
},
],
};
await this.s3.send(
new PutBucketPolicyCommand({
Bucket: bucket,
Policy: JSON.stringify(policy),
}),
);
Logger.debug({bucket}, 'Set public access policy on bucket');
}
} else {
throw error;
}
}
}
async purgeBucket(bucket: string): Promise<void> {
const command = new ListObjectsV2Command({Bucket: bucket});
const {Contents} = await this.s3.send(command);
if (!Contents) {
return;
}
await Promise.all(Contents.map(({Key}) => Key && this.deleteObject(bucket, Key)));
Logger.debug({bucket}, 'Purged bucket');
}
async uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void> {
const {prefix, key, body} = params;
await this.uploadObject({
bucket: Config.s3.buckets.cdn,
key: `${prefix}/${key}`,
body,
});
}
async deleteAvatar(params: {prefix: string; key: string}): Promise<void> {
const {prefix, key} = params;
await this.deleteObject(Config.s3.buckets.cdn, `${prefix}/${key}`);
}
async getObject(params: {bucket: string; key: string}): Promise<GetObjectCommandOutput> {
const command = new GetObjectCommand({
Bucket: params.bucket,
Key: params.key,
});
return await this.getClient(params.bucket).send(command);
}
async headObject(params: {bucket: string; key: string}): Promise<HeadObjectCommandOutput> {
const command = new HeadObjectCommand({
Bucket: params.bucket,
Key: params.key,
});
return await this.getClient(params.bucket).send(command);
}
async listObjects(params: {bucket: string; prefix: string}): Promise<ListObjectsV2CommandOutput> {
const command = new ListObjectsV2Command({
Bucket: params.bucket,
Prefix: params.prefix,
});
return await this.getClient(params.bucket).send(command);
}
async deleteObjects(params: {bucket: string; objects: Array<{Key: string}>}): Promise<void> {
if (params.objects.length === 0) return;
const command = new DeleteObjectsCommand({
Bucket: params.bucket,
Delete: {
Objects: params.objects,
},
});
await this.getClient(params.bucket).send(command);
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ICaptchaService, VerifyCaptchaParams} from '~/infrastructure/ICaptchaService';
export class TestCaptchaService implements ICaptchaService {
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}

View File

@@ -0,0 +1,349 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ITestEmailService, SentEmailRecord} from '~/infrastructure/IEmailService';
import {Logger} from '~/Logger';
export class TestEmailService implements ITestEmailService {
private sentEmails: Array<SentEmailRecord> = [];
listSentEmails(): Array<SentEmailRecord> {
return [...this.sentEmails];
}
clearSentEmails(): void {
this.sentEmails = [];
}
private recordEmail(params: {to: string; subject: string; type: string; metadata?: Record<string, string>}): boolean {
const {to, subject, type, metadata} = params;
this.sentEmails.push({
to,
subject,
type,
timestamp: new Date(),
metadata: metadata ?? {},
});
return true;
}
async sendPasswordResetEmail(
email: string,
username: string,
resetToken: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Password reset email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Reset token: ${resetToken}`);
return this.recordEmail({
to: email,
subject: 'Reset your Fluxer password',
type: 'password_reset',
metadata: {token: resetToken},
});
}
async sendEmailVerification(
email: string,
username: string,
verificationToken: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Email verification sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Verification token: ${verificationToken}`);
return this.recordEmail({
to: email,
subject: 'Verify your Fluxer email address',
type: 'email_verification',
metadata: {token: verificationToken},
});
}
async sendIpAuthorizationEmail(
email: string,
username: string,
authorizationToken: string,
ipAddress: string,
location: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] IP authorization email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] IP: ${ipAddress}, Location: ${location}`);
Logger.info(`[TestEmailService] Authorization token: ${authorizationToken}`);
return this.recordEmail({
to: email,
subject: 'Authorize login from new IP address',
type: 'ip_authorization',
metadata: {token: authorizationToken, ip: ipAddress, location},
});
}
async sendAccountDisabledForSuspiciousActivityEmail(
email: string,
username: string,
reason: string | null,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Account disabled email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Reason: ${reason ?? 'Not specified'}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer account has been temporarily disabled',
type: 'account_disabled_suspicious',
metadata: {reason: reason ?? ''},
});
}
async sendAccountTempBannedEmail(
email: string,
username: string,
reason: string | null,
durationHours: number,
bannedUntil: Date,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Temp ban email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Duration: ${durationHours} hours, until ${bannedUntil.toISOString()}`);
Logger.info(`[TestEmailService] Reason: ${reason ?? 'Not specified'}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer account has been temporarily suspended',
type: 'account_temp_banned',
metadata: {
reason: reason ?? '',
duration_hours: durationHours.toString(10),
banned_until: bannedUntil.toISOString(),
},
});
}
async sendAccountScheduledForDeletionEmail(
email: string,
username: string,
reason: string | null,
deletionDate: Date,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Scheduled deletion email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Deletion date: ${deletionDate.toISOString()}`);
Logger.info(`[TestEmailService] Reason: ${reason ?? 'Not specified'}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer account is scheduled for deletion',
type: 'account_scheduled_deletion',
metadata: {
reason: reason ?? '',
deletion_date: deletionDate.toISOString(),
},
});
}
async sendSelfDeletionScheduledEmail(
email: string,
username: string,
deletionDate: Date,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Self deletion email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Deletion date: ${deletionDate.toISOString()}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer account deletion has been scheduled',
type: 'self_deletion_scheduled',
metadata: {deletion_date: deletionDate.toISOString()},
});
}
async sendUnbanNotification(
email: string,
username: string,
reason: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Unban notification sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Reason: ${reason}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer account suspension has been lifted',
type: 'unban_notification',
metadata: {reason},
});
}
async sendScheduledDeletionNotification(
email: string,
username: string,
deletionDate: Date,
reason: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Scheduled deletion notification sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Deletion date: ${deletionDate.toISOString()}`);
Logger.info(`[TestEmailService] Reason: ${reason}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer account is scheduled for deletion',
type: 'scheduled_deletion_notification',
metadata: {deletion_date: deletionDate.toISOString(), reason},
});
}
async sendInactivityWarningEmail(
email: string,
username: string,
deletionDate: Date,
lastActiveDate: Date,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Inactivity warning email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Deletion date: ${deletionDate.toISOString()}`);
Logger.info(`[TestEmailService] Last active: ${lastActiveDate.toISOString()}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer account will be deleted due to inactivity',
type: 'inactivity_warning',
metadata: {
deletion_date: deletionDate.toISOString(),
last_active_date: lastActiveDate.toISOString(),
},
});
}
async sendHarvestCompletedEmail(
email: string,
username: string,
downloadUrl: string,
totalMessages: number,
fileSize: number,
expiresAt: Date,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Harvest completed email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Total messages: ${totalMessages}, File size: ${fileSize} bytes`);
Logger.info(`[TestEmailService] Download URL: ${downloadUrl}`);
Logger.info(`[TestEmailService] Expires at: ${expiresAt.toISOString()}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer Data Export is Ready',
type: 'harvest_completed',
metadata: {
download_url: downloadUrl,
total_messages: totalMessages.toString(10),
file_size: fileSize.toString(10),
expires_at: expiresAt.toISOString(),
},
});
}
async sendGiftChargebackNotification(email: string, username: string, _locale?: string | null): Promise<boolean> {
Logger.info(`[TestEmailService] Gift chargeback notification sent to ${email} for user ${username}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer Premium gift has been revoked',
type: 'gift_chargeback_notification',
});
}
async sendReportResolvedEmail(
email: string,
username: string,
reportId: string,
publicComment: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Report resolved email sent to ${email} for user ${username}`);
Logger.info(`[TestEmailService] Report ID: ${reportId}`);
Logger.info(`[TestEmailService] Comment: ${publicComment}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer report has been reviewed',
type: 'report_resolved',
metadata: {report_id: reportId, public_comment: publicComment},
});
}
async sendDsaReportVerificationCode(
email: string,
code: string,
expiresAt: Date,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] DSA report verification code sent to ${email}`);
Logger.info(`[TestEmailService] Verification code: ${code}`);
return this.recordEmail({
to: email,
subject: 'Verify your DSA report email',
type: 'dsa_report_verification',
metadata: {
code,
expires_at: expiresAt.toISOString(),
},
});
}
async sendRegistrationApprovedEmail(email: string, username: string, _locale?: string | null): Promise<boolean> {
Logger.info(`[TestEmailService] Registration approved email sent to ${email} for user ${username}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer registration has been approved',
type: 'registration_approved',
});
}
async sendEmailChangeOriginal(
email: string,
username: string,
code: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Email change original verification sent to ${email} for user ${username}`);
return this.recordEmail({
to: email,
subject: 'Confirm your Fluxer email change',
type: 'email_change_original',
metadata: {code},
});
}
async sendEmailChangeNew(email: string, username: string, code: string, _locale?: string | null): Promise<boolean> {
Logger.info(`[TestEmailService] Email change new verification sent to ${email} for user ${username}`);
return this.recordEmail({
to: email,
subject: 'Verify your new Fluxer email',
type: 'email_change_new',
metadata: {code},
});
}
async sendEmailChangeRevert(
email: string,
username: string,
newEmail: string,
token: string,
_locale?: string | null,
): Promise<boolean> {
Logger.info(`[TestEmailService] Email change revert notice sent to ${email} for user ${username}`);
return this.recordEmail({
to: email,
subject: 'Your Fluxer email was changed',
type: 'email_change_revert',
metadata: {token, new_email: newEmail},
});
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '~/Config';
import {FLUXER_USER_AGENT} from '~/Constants';
import type {ICaptchaService, VerifyCaptchaParams} from '~/infrastructure/ICaptchaService';
import {Logger} from '~/Logger';
interface TurnstileVerifyResponse {
success: boolean;
challenge_ts?: string;
hostname?: string;
'error-codes'?: Array<string>;
action?: string;
cdata?: string;
}
export class TurnstileService implements ICaptchaService {
private readonly secretKey: string;
private readonly verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
constructor() {
if (!Config.captcha.turnstile?.secretKey) {
throw new Error('TURNSTILE_SECRET_KEY is required when using Turnstile captcha');
}
this.secretKey = Config.captcha.turnstile.secretKey;
}
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
try {
const params = new URLSearchParams();
params.append('secret', this.secretKey);
params.append('response', token);
if (remoteIp) {
params.append('remoteip', remoteIp);
}
const response = await fetch(this.verifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': FLUXER_USER_AGENT,
},
body: params.toString(),
});
if (!response.ok) {
Logger.error({status: response.status}, 'Turnstile verify request failed');
return false;
}
const data = (await response.json()) as TurnstileVerifyResponse;
if (!data.success) {
Logger.warn({errorCodes: data['error-codes']}, 'Turnstile verification failed');
return false;
}
Logger.debug({hostname: data.hostname}, 'Turnstile verification successful');
return true;
} catch (error) {
Logger.error({error}, 'Error verifying Turnstile token');
return false;
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {filetypemime} from 'magic-bytes.js';
import type {MessageEmbedResponse} from '~/channel/EmbedTypes';
import type {ICacheService} from '~/infrastructure/ICacheService';
import type {IMediaService} from '~/infrastructure/IMediaService';
import {IUnfurlerService} from '~/infrastructure/IUnfurlerService';
import {Logger} from '~/Logger';
import {AudioResolver} from '~/unfurler/resolvers/AudioResolver';
import type {BaseResolver} from '~/unfurler/resolvers/BaseResolver';
import {BlueskyResolver} from '~/unfurler/resolvers/BlueskyResolver';
import {DefaultResolver} from '~/unfurler/resolvers/DefaultResolver';
import {HackerNewsResolver} from '~/unfurler/resolvers/HackerNewsResolver';
import {ImageResolver} from '~/unfurler/resolvers/ImageResolver';
import {TenorResolver} from '~/unfurler/resolvers/TenorResolver';
import {VideoResolver} from '~/unfurler/resolvers/VideoResolver';
import {WikipediaResolver} from '~/unfurler/resolvers/WikipediaResolver';
import {XkcdResolver} from '~/unfurler/resolvers/XkcdResolver';
import {YouTubeResolver} from '~/unfurler/resolvers/YouTubeResolver';
import * as FetchUtils from '~/utils/FetchUtils';
export class UnfurlerService extends IUnfurlerService {
private readonly resolvers: Array<BaseResolver>;
constructor(
private cacheService: ICacheService,
private mediaService: IMediaService,
) {
super();
this.resolvers = [
new AudioResolver(this.mediaService),
new HackerNewsResolver(this.mediaService),
new ImageResolver(this.mediaService),
new TenorResolver(this.mediaService),
new VideoResolver(this.mediaService),
new XkcdResolver(this.mediaService),
new YouTubeResolver(this.mediaService),
new WikipediaResolver(this.mediaService),
new BlueskyResolver(this.cacheService, this.mediaService),
new DefaultResolver(this.cacheService, this.mediaService),
];
}
async unfurl(url: string, isNSFWAllowed: boolean = false): Promise<Array<MessageEmbedResponse>> {
try {
const response = await FetchUtils.sendRequest({url, timeout: 10_000});
if (response.status !== 200) {
Logger.debug({url, status: response.status}, 'Non-200 response received');
return [];
}
const contentBuffer = await this.streamToBuffer(response.stream);
const mimeType = this.determineMimeType(contentBuffer, response.headers);
if (!mimeType) {
Logger.error({url}, 'Unable to determine MIME type');
return [];
}
const finalUrl = new URL(response.url);
for (const resolver of this.resolvers) {
if (resolver.match(finalUrl, mimeType, contentBuffer)) {
return resolver.resolve(finalUrl, contentBuffer, isNSFWAllowed);
}
}
return [];
} catch (error) {
Logger.error({error, url}, 'Failed to unfurl URL');
return [];
}
}
private async streamToBuffer(stream: NodeJS.ReadableStream): Promise<Uint8Array> {
const chunks: Array<Uint8Array> = [];
for await (const chunk of stream) {
chunks.push(new Uint8Array(Buffer.from(chunk)));
}
return new Uint8Array(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
}
private determineMimeType(content: Uint8Array, headers: Headers): string | undefined {
const headerMimeType = headers.get('content-type')?.split(';')[0];
if (headerMimeType) return headerMimeType;
const [mimeTypeFromMagicBytes] = filetypemime(new Uint8Array(content));
return mimeTypeFromMagicBytes;
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '~/BrandedTypes';
import {UserFlags} from '~/Constants';
import {UnknownUserError} from '~/Errors';
import type {ICacheService} from '~/infrastructure/ICacheService';
import {InMemoryCoalescer} from '~/infrastructure/InMemoryCoalescer';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IUserRepository} from '~/user/IUserRepository';
import type {UserPartialResponse} from '~/user/UserModel';
import {mapUserToPartialResponse} from '~/user/UserModel';
export class UserCacheService {
private coalescer = new InMemoryCoalescer();
constructor(
public readonly cacheService: ICacheService,
private userRepository: IUserRepository,
) {}
async getUserPartialResponse(userId: UserID, requestCache: RequestCache): Promise<UserPartialResponse> {
const cached = requestCache.userPartials.get(userId);
if (cached) {
return cached;
}
const cacheKey = `user:partial:${userId}`;
const redisCached = await this.cacheService.getAndRenewTtl<UserPartialResponse>(cacheKey, 300);
if (redisCached) {
requestCache.userPartials.set(userId, redisCached);
return redisCached;
}
const userPartialResponse = await this.coalescer.coalesce(cacheKey, async () => {
const user = await this.userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (user.flags & UserFlags.DELETED) {
throw new UnknownUserError();
}
return mapUserToPartialResponse(user);
});
await this.cacheService.set(cacheKey, userPartialResponse, 300);
requestCache.userPartials.set(userId, userPartialResponse);
return userPartialResponse;
}
async invalidateUserCache(userId: UserID): Promise<void> {
const cacheKey = `user:partial:${userId}`;
await this.cacheService.delete(cacheKey);
}
async getUserPartialResponses(
userIds: Array<UserID>,
requestCache: RequestCache,
): Promise<Map<UserID, UserPartialResponse>> {
const results = new Map<UserID, UserPartialResponse>();
const promises = userIds.map(async (userId) => {
const userResponse = await this.getUserPartialResponse(userId, requestCache);
results.set(userId, userResponse);
});
await Promise.all(promises);
return results;
}
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {Config} from '~/Config';
import {InstanceConfigRepository} from '~/instance/InstanceConfigRepository';
import {ClamAV} from './ClamAV';
import type {ICacheService} from './ICacheService';
import type {IVirusScanService, VirusScanResult} from './IVirusScanService';
export class VirusScanService implements IVirusScanService {
private clamav: ClamAV;
private readonly CACHE_TTL = 60 * 60 * 24 * 7;
private static readonly ALERT_SAMPLE_RATE = 0.05;
private static readonly ERROR_FIELD_LIMIT = 900;
private instanceConfigRepository: InstanceConfigRepository;
constructor(private cacheService: ICacheService) {
this.clamav = new ClamAV();
this.instanceConfigRepository = new InstanceConfigRepository();
}
async initialize(): Promise<void> {}
async scanFile(filePath: string): Promise<VirusScanResult> {
const buffer = await fs.readFile(filePath);
const filename = path.basename(filePath);
return this.scanBuffer(buffer, filename);
}
async scanBuffer(buffer: Buffer, filename: string): Promise<VirusScanResult> {
const fileHash = crypto.createHash('sha256').update(buffer).digest('hex');
const isCachedVirus = await this.isVirusHashCached(fileHash);
if (isCachedVirus) {
return {
isClean: false,
threat: 'Cached virus signature',
fileHash,
};
}
try {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, `scan_${Date.now()}_${filename}`);
await fs.writeFile(tempFilePath, buffer);
try {
const scanResult = await this.clamav.scanFile(tempFilePath);
if (!scanResult.isClean) {
await this.cacheVirusHash(fileHash);
return {
isClean: false,
threat: scanResult.virus || 'Virus detected',
fileHash,
};
}
return {
isClean: true,
fileHash,
};
} finally {
try {
await fs.unlink(tempFilePath);
} catch (error) {
console.warn('Failed to cleanup temp file:', tempFilePath, error);
}
}
} catch (error) {
console.error('ClamAV scan failed:', error);
void this.reportScanFailure(error, filename, fileHash);
if (Config.clamav.failOpen) {
return {
isClean: true,
fileHash,
};
}
throw new Error(`Virus scan failed: ${this.describeError(error)}`);
}
}
async isVirusHashCached(fileHash: string): Promise<boolean> {
const cacheKey = `virus:${fileHash}`;
const cached = await this.cacheService.get(cacheKey);
return cached != null;
}
async cacheVirusHash(fileHash: string): Promise<void> {
const cacheKey = `virus:${fileHash}`;
await this.cacheService.set(cacheKey, 'true', this.CACHE_TTL);
}
private describeError(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}
private truncateText(value: string, limit: number): string {
if (value.length <= limit) return value;
return `${value.slice(0, limit - 3)}...`;
}
private async reportScanFailure(error: unknown, filename: string, fileHash: string): Promise<void> {
const instanceConfig = await this.instanceConfigRepository.getInstanceConfig();
const webhookUrl = instanceConfig.systemAlertsWebhookUrl;
if (!webhookUrl) return;
if (Math.random() >= VirusScanService.ALERT_SAMPLE_RATE) return;
const errorDescription = this.truncateText(this.describeError(error), VirusScanService.ERROR_FIELD_LIMIT);
const payload = {
username: 'Virus Scan Monitor',
content: 'A virus scan failed to complete.',
embeds: [
{
title: 'Virus scan failure detected',
description: `Unable to scan attachment ${filename}`,
color: 0xe53e3e,
fields: [
{
name: 'Scan mode',
value: Config.clamav.failOpen ? 'Fail-open' : 'Fail-closed',
inline: true,
},
{
name: 'File hash',
value: fileHash,
},
{
name: 'Error',
value: errorDescription || 'Unknown error',
},
],
timestamp: new Date().toISOString(),
},
],
};
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text();
console.warn('Failed to deliver virus scan alert', response.status, body);
}
} catch (broadcastError) {
console.warn('Failed to deliver virus scan alert', broadcastError);
}
}
}

View File

@@ -0,0 +1,184 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {z} from 'zod';
import type {ChannelID, GuildID, UserID} from '~/BrandedTypes';
import {createChannelID, createGuildID, createUserID} from '~/BrandedTypes';
interface DMRoomContext {
readonly type: 'dm';
readonly channelId: ChannelID;
}
interface GuildRoomContext {
readonly type: 'guild';
readonly channelId: ChannelID;
readonly guildId: GuildID;
}
type VoiceRoomContext = DMRoomContext | GuildRoomContext;
interface DMParticipantContext {
readonly type: 'dm';
readonly userId: UserID;
readonly channelId: ChannelID;
readonly connectionId: string;
}
interface GuildParticipantContext {
readonly type: 'guild';
readonly userId: UserID;
readonly channelId: ChannelID;
readonly connectionId: string;
readonly guildId: GuildID;
}
type ParticipantContext = DMParticipantContext | GuildParticipantContext;
const SnowflakeStringSchema = z.string().regex(/^\d+$/, 'Must be a numeric string');
const DMParticipantMetadataSchema = z.object({
user_id: SnowflakeStringSchema,
channel_id: SnowflakeStringSchema,
connection_id: z.string().min(1),
dm_call: z.union([z.literal('true'), z.literal(true)]),
region_id: z.string().optional(),
server_id: z.string().optional(),
});
const GuildParticipantMetadataSchema = z.object({
user_id: SnowflakeStringSchema,
channel_id: SnowflakeStringSchema,
connection_id: z.string().min(1),
guild_id: SnowflakeStringSchema,
region_id: z.string().optional(),
server_id: z.string().optional(),
});
const ParticipantMetadataSchema = z.union([DMParticipantMetadataSchema, GuildParticipantMetadataSchema]);
type RawParticipantMetadata = z.infer<typeof ParticipantMetadataSchema>;
const DM_ROOM_PREFIX = 'dm_channel_';
const GUILD_ROOM_PREFIX = 'guild_';
export function parseRoomName(roomName: string): VoiceRoomContext | null {
if (roomName.startsWith(DM_ROOM_PREFIX)) {
const channelIdStr = roomName.slice(DM_ROOM_PREFIX.length);
try {
return {
type: 'dm',
channelId: createChannelID(BigInt(channelIdStr)),
};
} catch {
return null;
}
}
if (roomName.startsWith(GUILD_ROOM_PREFIX)) {
const parts = roomName.split('_');
if (parts.length === 4 && parts[0] === 'guild' && parts[2] === 'channel') {
try {
return {
type: 'guild',
guildId: createGuildID(BigInt(parts[1])),
channelId: createChannelID(BigInt(parts[3])),
};
} catch {
return null;
}
}
}
return null;
}
export function parseParticipantMetadataWithRaw(
metadata: string,
): {context: ParticipantContext; raw: RawParticipantMetadata} | null {
try {
const parsed = JSON.parse(metadata);
const result = ParticipantMetadataSchema.safeParse(parsed);
if (!result.success) {
return null;
}
const data = result.data;
const userId = createUserID(BigInt(data.user_id));
const channelId = createChannelID(BigInt(data.channel_id));
const connectionId = data.connection_id;
if ('dm_call' in data) {
return {
context: {
type: 'dm',
userId,
channelId,
connectionId,
},
raw: data,
};
}
return {
context: {
type: 'guild',
userId,
channelId,
connectionId,
guildId: createGuildID(BigInt(data.guild_id)),
},
raw: data,
};
} catch {
return null;
}
}
export function isDMRoom(context: VoiceRoomContext): context is DMRoomContext {
return context.type === 'dm';
}
const PARTICIPANT_IDENTITY_PREFIX = 'user_';
interface ParticipantIdentity {
readonly userId: UserID;
readonly connectionId: string;
}
export function parseParticipantIdentity(identity: string): ParticipantIdentity | null {
if (!identity.startsWith(PARTICIPANT_IDENTITY_PREFIX)) {
return null;
}
const parts = identity.split('_');
if (parts.length !== 3 || parts[0] !== 'user') {
return null;
}
try {
return {
userId: createUserID(BigInt(parts[1])),
connectionId: parts[2],
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Redis} from 'ioredis';
import type {ChannelID, GuildID} from '~/BrandedTypes';
import {VOICE_OCCUPANCY_REGION_KEY_PREFIX, VOICE_OCCUPANCY_SERVER_KEY_PREFIX} from '~/voice/VoiceConstants';
export class VoiceRoomStore {
private redis: Redis;
private readonly keyPrefix = 'voice:room:server';
constructor(redis: Redis) {
this.redis = redis;
}
private getRoomKey(guildId: GuildID | undefined, channelId: ChannelID): string {
if (guildId === undefined) {
return `${this.keyPrefix}:dm:${channelId}`;
}
return `${this.keyPrefix}:guild:${guildId}:${channelId}`;
}
async pinRoomServer(
guildId: GuildID | undefined,
channelId: ChannelID,
regionId: string,
serverId: string,
endpoint: string,
): Promise<void> {
const key = this.getRoomKey(guildId, channelId);
const previous = await this.getPinnedRoomServer(guildId, channelId);
if (previous) {
await this.removeOccupancy(previous.regionId, previous.serverId, guildId, channelId);
}
await this.redis.set(
key,
JSON.stringify({
regionId,
serverId,
endpoint,
updatedAt: new Date().toISOString(),
}),
);
await this.addOccupancy(regionId, serverId, guildId, channelId);
}
async getPinnedRoomServer(
guildId: GuildID | undefined,
channelId: ChannelID,
): Promise<{regionId: string; serverId: string; endpoint: string} | null> {
const key = this.getRoomKey(guildId, channelId);
const data = await this.redis.get(key);
if (!data) return null;
const parsed = JSON.parse(data) as {regionId?: string; serverId?: string; endpoint?: string};
if (!parsed.regionId || !parsed.serverId || !parsed.endpoint) {
return null;
}
return {
regionId: parsed.regionId,
serverId: parsed.serverId,
endpoint: parsed.endpoint,
};
}
async deleteRoomServer(guildId: GuildID | undefined, channelId: ChannelID): Promise<void> {
const key = this.getRoomKey(guildId, channelId);
const previous = await this.getPinnedRoomServer(guildId, channelId);
await this.redis.del(key);
if (previous) {
await this.removeOccupancy(previous.regionId, previous.serverId, guildId, channelId);
}
}
async getRegionOccupancy(regionId: string): Promise<Array<string>> {
const key = `${VOICE_OCCUPANCY_REGION_KEY_PREFIX}:${regionId}`;
const members = await this.redis.smembers(key);
return members;
}
async getServerOccupancy(regionId: string, serverId: string): Promise<Array<string>> {
const key = `${VOICE_OCCUPANCY_SERVER_KEY_PREFIX}:${regionId}:${serverId}`;
const members = await this.redis.smembers(key);
return members;
}
private async addOccupancy(
regionId: string,
serverId: string,
guildId: GuildID | undefined,
channelId: ChannelID,
): Promise<void> {
const member = this.buildOccupancyMember(guildId, channelId);
const regionKey = `${VOICE_OCCUPANCY_REGION_KEY_PREFIX}:${regionId}`;
const serverKey = `${VOICE_OCCUPANCY_SERVER_KEY_PREFIX}:${regionId}:${serverId}`;
await this.redis.multi().sadd(regionKey, member).sadd(serverKey, member).exec();
}
private async removeOccupancy(
regionId: string,
serverId: string,
guildId: GuildID | undefined,
channelId: ChannelID,
): Promise<void> {
const member = this.buildOccupancyMember(guildId, channelId);
const regionKey = `${VOICE_OCCUPANCY_REGION_KEY_PREFIX}:${regionId}`;
const serverKey = `${VOICE_OCCUPANCY_SERVER_KEY_PREFIX}:${regionId}:${serverId}`;
await this.redis.multi().srem(regionKey, member).srem(serverKey, member).exec();
}
private buildOccupancyMember(guildId: GuildID | undefined, channelId: ChannelID): string {
if (!guildId) {
return `dm:${channelId.toString()}`;
}
return `guild:${guildId.toString()}:channel:${channelId.toString()}`;
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ar} from './locales/ar';
import {bg} from './locales/bg';
import {cs} from './locales/cs';
import {da} from './locales/da';
import {de} from './locales/de';
import {el} from './locales/el';
import {enGB} from './locales/en-GB';
import {enUS} from './locales/en-US';
import {es419} from './locales/es-419';
import {esES} from './locales/es-ES';
import {fi} from './locales/fi';
import {fr} from './locales/fr';
import {he} from './locales/he';
import {hi} from './locales/hi';
import {hr} from './locales/hr';
import {hu} from './locales/hu';
import {id} from './locales/id';
import {it} from './locales/it';
import {ja} from './locales/ja';
import {ko} from './locales/ko';
import {lt} from './locales/lt';
import {nl} from './locales/nl';
import {no} from './locales/no';
import {pl} from './locales/pl';
import {ptBR} from './locales/pt-BR';
import {ro} from './locales/ro';
import {ru} from './locales/ru';
import {svSE} from './locales/sv-SE';
import {th} from './locales/th';
import {tr} from './locales/tr';
import {uk} from './locales/uk';
import {vi} from './locales/vi';
import {zhCN} from './locales/zh-CN';
import {zhTW} from './locales/zh-TW';
import type {EmailTranslations} from './types';
const locales: Record<string, EmailTranslations> = {
'en-US': enUS,
'en-GB': enGB,
ar,
bg,
cs,
da,
de,
el,
'es-ES': esES,
'es-419': es419,
fi,
fr,
he,
hi,
hr,
hu,
id,
it,
ja,
ko,
lt,
nl,
no,
pl,
'pt-BR': ptBR,
ro,
ru,
'sv-SE': svSE,
th,
tr,
uk,
vi,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
export function getLocaleTranslations(locale: string): EmailTranslations {
return locales[locale] || locales['en-US'];
}
export function hasLocale(locale: string): boolean {
return locale in locales;
}
export type {
EmailTemplate,
EmailTemplateKey,
EmailTemplateVariables,
EmailTranslations,
} from './types';

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const ar: EmailTranslations = {
passwordReset: {
subject: 'إعادة تعيين كلمة مرور حسابك على Fluxer',
body: `مرحباً {username}،
لقد طلبت إعادة تعيين كلمة المرور لحسابك على Fluxer. يرجى اتباع الرابط أدناه لتعيين كلمة مرور جديدة:
{resetUrl}
إذا لم تكن قد طلبت إعادة تعيين كلمة المرور، يمكنك تجاهل هذه الرسالة بأمان.
ستنتهي صلاحية هذا الرابط خلال ساعة واحدة.
- فريق Fluxer`,
},
emailVerification: {
subject: 'تأكيد عنوان بريدك الإلكتروني على Fluxer',
body: `مرحباً {username}،
يرجى تأكيد عنوان بريدك الإلكتروني لحسابك على Fluxer من خلال النقر على الرابط أدناه:
{verifyUrl}
إذا لم تقم بإنشاء حساب على Fluxer، يمكنك تجاهل هذه الرسالة بأمان.
ستنتهي صلاحية هذا الرابط خلال 24 ساعة.
- فريق Fluxer`,
},
ipAuthorization: {
subject: 'السماح بتسجيل الدخول من عنوان IP جديد',
body: `مرحباً {username}،
اكتشفنا محاولة تسجيل دخول إلى حسابك على Fluxer من عنوان IP جديد:
عنوان IP: {ipAddress}
الموقع: {location}
إذا كانت هذه المحاولة منك، يرجى السماح لهذا العنوان من خلال النقر على الرابط أدناه:
{authUrl}
إذا لم تحاول تسجيل الدخول، فيرجى تغيير كلمة مرورك فوراً.
ستنتهي صلاحية رابط التفويض هذا خلال 30 دقيقة.
- فريق Fluxer`,
},
accountDisabledSuspicious: {
subject: 'تم تعطيل حسابك على Fluxer مؤقتاً',
body: `مرحباً {username}،
تم تعطيل حسابك على Fluxer مؤقتاً بسبب نشاط مريب.
{reason, select,
null {}
other {السبب: {reason}
}}لاستعادة الوصول إلى حسابك، يجب عليك إعادة تعيين كلمة المرور:
{forgotUrl}
بعد إعادة تعيين كلمة المرور، ستتمكن من تسجيل الدخول مرة أخرى.
إذا كنت تعتقد أن هذا الإجراء تم عن طريق الخطأ، فيرجى التواصل مع فريق الدعم لدينا.
- فريق سلامة Fluxer`,
},
accountTempBanned: {
subject: 'تم إيقاف حسابك على Fluxer مؤقتاً',
body: `مرحباً {username}،
تم إيقاف حسابك على Fluxer مؤقتاً بسبب انتهاك شروط الخدمة أو إرشادات المجتمع.
المدة: {durationHours, plural,
=1 {ساعة واحدة}
other {# ساعات}
}
معلّق حتى: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
السبب: {reason}}
}
خلال هذه الفترة، لن تتمكن من الوصول إلى حسابك.
نوصيك بمراجعة:
- شروط الخدمة: {termsUrl}
- إرشادات المجتمع: {guidelinesUrl}
إذا كنت تعتقد أن قرار الإنفاذ هذا غير صحيح أو غير مبرّر، يمكنك تقديم استئناف إلى appeals@fluxer.app من عنوان البريد الإلكتروني هذا. يرجى شرح سبب اعتقادك بأن القرار كان خاطئاً بوضوح. سنقوم بمراجعة الاستئناف والرد عليك بالقرار.
- فريق سلامة Fluxer`,
},
accountScheduledDeletion: {
subject: 'تم جدولة حذف حسابك على Fluxer',
body: `مرحباً {username}،
تم جدولة حسابك على Fluxer للحذف النهائي بسبب انتهاكات لشروط الخدمة أو إرشادات المجتمع.
تاريخ الحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
السبب: {reason}}
}
هذا إجراء إنفاذ جدي. سيتم حذف بيانات حسابك بشكل دائم في التاريخ المحدد.
نوصيك بمراجعة:
- شروط الخدمة: {termsUrl}
- إرشادات المجتمع: {guidelinesUrl}
عملية الاستئناف:
إذا كنت تعتقد أن قرار الإنفاذ هذا غير صحيح أو غير مبرّر، لديك 30 يوماً لتقديم استئناف إلى appeals@fluxer.app من عنوان البريد الإلكتروني هذا.
في استئنافك، يرجى:
- شرح سبب اعتقادك بأن قرار الإنفاذ غير صحيح أو غير مبرّر بشكل واضح
- تقديم أي أدلة أو سياق ذي صلة
سيقوم أحد أعضاء فريق سلامة Fluxer بمراجعة استئناك وقد يقوم بإيقاف الحذف المعلّق حتى يتم الوصول إلى قرار نهائي.
- فريق سلامة Fluxer`,
},
selfDeletionScheduled: {
subject: 'تم جدولة حذف حسابك على Fluxer',
body: `مرحباً {username}،
يحزننا أن نراك ترحل! تم جدولة حذف حسابك على Fluxer.
تاريخ الحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}
مهم: يمكنك إلغاء عملية الحذف في أي وقت قبل {deletionDate, date, full} {deletionDate, time, short} بمجرد تسجيل الدخول إلى حسابك مرة أخرى.
قبل أن تغادر:
لوحة الخصوصية في إعدادات المستخدم تتيح لك:
- حذف رسائلك على المنصة
- استخراج أي بيانات مهمة قبل المغادرة
يرجى ملاحظة: بعد حذف حسابك، لن يكون من الممكن حذف رسائلك. إذا كنت ترغب في حذف رسائلك، يرجى القيام بذلك من خلال لوحة الخصوصية قبل إتمام حذف الحساب.
إذا غيّرت رأيك، فقط سجّل الدخول مرة أخرى لإلغاء الحذف.
- فريق Fluxer`,
},
inactivityWarning: {
subject: 'سيتم حذف حسابك على Fluxer بسبب عدم النشاط',
body: `مرحباً {username}،
لاحظنا أنك لم تسجّل الدخول إلى حسابك على Fluxer لمدة تزيد عن عامين.
آخر تسجيل دخول: {lastActiveDate, date, full} {lastActiveDate, time, short}
كجزء من سياسة الاحتفاظ بالبيانات لدينا، يتم تلقائياً جدولة حذف الحسابات غير النشطة. سيتم حذف حسابك بشكل دائم في التاريخ التالي:
تاريخ الحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}
كيفية الحفاظ على حسابك:
يكفي أن تقوم بتسجيل الدخول إلى حسابك عبر {loginUrl} قبل تاريخ الحذف لإلغاء هذا الحذف التلقائي. لا يلزم اتخاذ أي إجراء آخر.
ماذا يحدث إذا لم تقم بتسجيل الدخول:
- سيتم حذف حسابك وجميع البيانات المرتبطة به بشكل دائم
- سيتم إرجاع رسائلك بشكل مجهول (منسوبة إلى "مستخدم محذوف")
- لا يمكن التراجع عن هذا الإجراء
هل تريد حذف رسائلك؟
إذا كنت ترغب في حذف رسائلك قبل حذف حسابك، يرجى تسجيل الدخول واستخدام لوحة الخصوصية في إعدادات المستخدم.
نأمل أن نراك مجدداً على Fluxer!
- فريق Fluxer`,
},
harvestCompleted: {
subject: 'تصدير بياناتك من Fluxer جاهز للتنزيل',
body: `مرحباً {username}،
تم الانتهاء من تصدير بياناتك وهو جاهز للتنزيل!
ملخص التصدير:
- إجمالي الرسائل: {totalMessages, number}
- حجم الملف: {fileSizeMB} ميغابايت
- الصيغة: ملف ZIP يحتوي على ملفات JSON
قم بتنزيل بياناتك: {downloadUrl}
مهم: سينتهي مفعول رابط التنزيل هذا في {expiresAt, date, full} {expiresAt, time, short}
ما الذي يتضمنه التصدير:
- جميع رسائلك منظّمة حسب القناة
- بيانات القنوات
- ملفك الشخصي ومعلومات حسابك
- عضويات الخوادم والإعدادات
- الجلسات الخاصة بالمصادقة ومعلومات الأمان
يتم تنظيم البيانات بصيغة JSON لتسهيل قراءتها وتحليلها.
إذا كانت لديك أي أسئلة حول تصدير بياناتك، فيرجى التواصل مع support@fluxer.app
- فريق Fluxer`,
},
unbanNotification: {
subject: 'تم رفع إيقاف حسابك على Fluxer',
body: `مرحباً {username}،
أخبار سارّة! تم رفع إيقاف حسابك على Fluxer.
السبب: {reason}
يمكنك الآن تسجيل الدخول إلى حسابك ومتابعة استخدام Fluxer.
- فريق سلامة Fluxer`,
},
scheduledDeletionNotification: {
subject: 'تم جدولة حذف حسابك على Fluxer',
body: `مرحباً {username}،
تم جدولة حسابك على Fluxer للحذف النهائي.
تاريخ الحذف المجدول: {deletionDate, date, full} {deletionDate, time, short}
السبب: {reason}
هذا إجراء إنفاذ جدي. سيتم حذف بيانات حسابك بشكل دائم في التاريخ المحدد.
إذا كنت تعتقد أن قرار الإنفاذ هذا غير صحيح، يمكنك تقديم استئناف إلى appeals@fluxer.app من عنوان البريد الإلكتروني هذا.
- فريق سلامة Fluxer`,
},
giftChargebackNotification: {
subject: 'تم إلغاء هديّة Fluxer Premium الخاصة بك',
body: `مرحباً {username}،
نود إبلاغك بأنه تم إلغاء هديّة Fluxer Premium التي قمت باستردادها بسبب نزاع دفع (استرجاع مبلغ) تم تقديمه من المشتري الأصلي.
تمت إزالة مزايا Premium من حسابك. تم اتخاذ هذا الإجراء لأن عملية الدفع الخاصة بالهدية تم الاعتراض عليها واسترجاعها.
إذا كانت لديك أي أسئلة بهذا الشأن، يرجى التواصل مع support@fluxer.app.
- فريق Fluxer`,
},
reportResolved: {
subject: 'تمت مراجعة بلاغك على Fluxer',
body: `مرحباً {username}،
تمت مراجعة بلاغك (المعرّف: {reportId}) من قبل فريق السلامة لدينا.
رد فريق السلامة:
{publicComment}
شكراً لمساهمتك في الحفاظ على Fluxer مكاناً آمناً للجميع. نحن نتعامل مع جميع البلاغات بجدية ونقدّر مساهمتك في مجتمعنا.
إذا كانت لديك أي أسئلة أو مخاوف بشأن هذه النتيجة، يرجى التواصل مع safety@fluxer.app.
- فريق سلامة Fluxer`,
},
dsaReportVerification: {
subject: 'تحقق من بريدك الإلكتروني لبلاغ DSA',
body: `مرحباً,
استخدم رمز التحقق التالي لتقديم بلاغك بموجب قانون الخدمات الرقمية على Fluxer:
{code}
تنتهي صلاحية هذا الرمز في {expiresAt, date, full} {expiresAt, time, short}.
إذا لم تطلب هذا، يرجى تجاهل هذه الرسالة.
- فريق سلامة Fluxer`,
},
registrationApproved: {
subject: 'تمت الموافقة على تسجيلك في Fluxer',
body: `مرحباً {username}،
أخبار رائعة! تمت الموافقة على تسجيلك في Fluxer.
يمكنك الآن تسجيل الدخول إلى تطبيق Fluxer عبر:
{channelsUrl}
مرحباً بك في مجتمع Fluxer!
- فريق Fluxer`,
},
emailChangeRevert: {
subject: 'تم تغيير بريدك الإلكتروني في Fluxer',
body: `مرحبًا {username},
تم تغيير بريد حسابك في Fluxer إلى {newEmail}.
إذا أجريت هذا التغيير، فلا حاجة لاتخاذ أي إجراء. إذا لم تفعل، يمكنك التراجع وحماية حسابك عبر هذا الرابط:
{revertUrl}
سيؤدي ذلك إلى استعادة بريدك السابق، وتسجيل خروجك من كل الجلسات، وإزالة أرقام الهواتف المرتبطة، وتعطيل MFA، وطلب كلمة مرور جديدة.
- فريق الأمان في Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const bg: EmailTranslations = {
passwordReset: {
subject: 'Нулиране на паролата ви за Fluxer',
body: `Здравей, {username},
Поискахте да нулирате паролата за вашия Fluxer акаунт. Моля, последвайте връзката по-долу, за да зададете нова парола:
{resetUrl}
Ако не сте поискали нулиране на паролата, можете спокойно да игнорирате този имейл.
Тази връзка ще изтече след 1 час.
- Екипът на Fluxer`,
},
emailVerification: {
subject: 'Потвърдете своя имейл адрес за Fluxer',
body: `Здравей, {username},
Моля, потвърдете имейл адреса за вашия Fluxer акаунт, като кликнете върху връзката по-долу:
{verifyUrl}
Ако не сте създали Fluxer акаунт, можете спокойно да игнорирате този имейл.
Тази връзка ще изтече след 24 часа.
- Екипът на Fluxer`,
},
ipAuthorization: {
subject: 'Разрешаване на вход от нов IP адрес',
body: `Здравей, {username},
Забелязахме опит за вход във вашия Fluxer акаунт от нов IP адрес:
IP адрес: {ipAddress}
Местоположение: {location}
Ако това сте били вие, моля, разрешете този IP адрес, като кликнете върху връзката по-долу:
{authUrl}
Ако не сте опитвали да влезете, моля, сменете паролата си незабавно.
Връзката за разрешаване ще изтече след 30 минути.
- Екипът на Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Вашият Fluxer акаунт е временно деактивиран',
body: `Здравей, {username},
Вашият Fluxer акаунт беше временно деактивиран поради съмнителна активност.
{reason, select,
null {}
other {Причина: {reason}
}}За да възстановите достъпа до акаунта си, трябва да нулирате паролата:
{forgotUrl}
След като нулирате паролата, отново ще можете да влизате в акаунта си.
Ако смятате, че това действие е извършено по грешка, моля, свържете се с нашия екип за поддръжка.
- Екипът по безопасността на Fluxer`,
},
accountTempBanned: {
subject: 'Вашият Fluxer акаунт е временно спрян',
body: `Здравей, {username},
Вашият Fluxer акаунт беше временно спрян поради нарушение на нашите Общи условия или Правила на общността.
Продължителност: {durationHours, plural,
=1 {1 час}
other {# часа}
}
Спрян до: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Причина: {reason}}
}
През този период няма да можете да влизате в акаунта си.
Настоятелно ви препоръчваме да прегледате:
- Общи условия: {termsUrl}
- Правила на общността: {guidelinesUrl}
Ако смятате, че това решение за налагане на мярка е неправилно или неоправдано, можете да подадете жалба на appeals@fluxer.app от този имейл адрес. Моля, ясно обяснете защо смятате, че решението е грешно. Ние ще прегледаме жалбата ви и ще ви отговорим с нашето решение.
- Екипът по безопасността на Fluxer`,
},
accountScheduledDeletion: {
subject: 'Вашият Fluxer акаунт е планиран за изтриване',
body: `Здравей, {username},
Вашият Fluxer акаунт е планиран за постоянно изтриване поради нарушения на нашите Общи условия или Правила на общността.
Планирана дата за изтриване: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Причина: {reason}}
}
Това е сериозна мярка за налагане на правила. Данните от акаунта ви ще бъдат изтрити завинаги на посочената дата.
Настоятелно ви препоръчваме да прегледате:
- Общи условия: {termsUrl}
- Правила на общността: {guidelinesUrl}
ПРОЦЕДУРА ЗА ОБЖАЛВАНЕ:
Ако смятате, че това решение за налагане на мярка е неправилно или неоправдано, имате 30 дни да подадете жалба на appeals@fluxer.app от този имейл адрес.
В жалбата си, моля:
- Обяснете ясно защо смятате, че решението е неправилно или неоправдано
- Предоставете всякакви релевантни доказателства или допълнителен контекст
Член на екипа по безопасността на Fluxer ще прегледа жалбата ви и може да спре планираното изтриване, докато не бъде взето окончателно решение.
- Екипът по безопасността на Fluxer`,
},
selfDeletionScheduled: {
subject: 'Планирахте изтриване на своя Fluxer акаунт',
body: `Здравей, {username},
Съжаляваме, че си тръгвате! Вашият Fluxer акаунт е планиран за изтриване.
Планирана дата за изтриване: {deletionDate, date, full} {deletionDate, time, short}
ВАЖНО: Можете да отмените това изтриване по всяко време преди {deletionDate, date, full} {deletionDate, time, short}, като просто влезете отново в акаунта си.
ПРЕДИ ДА СИ ТРЪГНЕТЕ:
Таблото за поверителност в Настройки на потребителя ви позволява да:
- Изтривате своите съобщения в платформата
- Експортирате важни данни преди да напуснете
Моля, имайте предвид: След като акаунтът ви бъде изтрит, няма да има начин да изтриете съобщенията си. Ако искате да изтриете съобщенията си, моля, направете го през Таблото за поверителност преди окончателното изтриване на акаунта.
Ако промените решението си, просто влезте отново, за да отмените изтриването.
- Екипът на Fluxer`,
},
inactivityWarning: {
subject: 'Вашият Fluxer акаунт ще бъде изтрит поради неактивност',
body: `Здравей, {username},
Забелязахме, че не сте влизали във вашия Fluxer акаунт повече от 2 години.
Последно влизане: {lastActiveDate, date, full} {lastActiveDate, time, short}
Като част от нашата политика за съхранение на данни, неактивните акаунти автоматично се планират за изтриване. Вашият акаунт ще бъде окончателно изтрит на:
Планирана дата за изтриване: {deletionDate, date, full} {deletionDate, time, short}
КАК ДА ЗАПАЗИТЕ АКАУНТА СИ:
Достатъчно е да влезете в акаунта си на {loginUrl} преди датата на изтриване, за да отмените това автоматично изтриване. Не се изисква друга действие.
КАКВО СЕ СЛУЧВА, АКО НЕ ВЛЕЗЕТЕ:
- Вашият акаунт и всички свързани с него данни ще бъдат окончателно изтрити
- Съобщенията ви ще бъдат анонимизирани (отбелязани като „Изтрит потребител“)
- Това действие е необратимо
ИСКАТЕ ДА ИЗТРИЕТЕ СЪОБЩЕНИЯТА СИ?
Ако искате да изтриете съобщенията си преди акаунтът ви да бъде изтрит, моля, влезте и използвайте Таблото за поверителност в Настройки на потребителя.
Надяваме се отново да ви видим във Fluxer!
- Екипът на Fluxer`,
},
harvestCompleted: {
subject: 'Вашият експорт на данни от Fluxer е готов',
body: `Здравей, {username},
Експортът на вашите данни беше завършен и е готов за изтегляне!
Обобщение на експорта:
- Общо съобщения: {totalMessages, number}
- Размер на файла: {fileSizeMB} MB
- Формат: ZIP архив с JSON файлове
Изтеглете данните си: {downloadUrl}
ВАЖНО: Тази връзка за изтегляне ще изтече на {expiresAt, date, full} {expiresAt, time, short}
Какво е включено в експорта:
- Всички ваши съобщения, организирани по канали
- Метаданни за каналите
- Вашият потребителски профил и информация за акаунта
- Членства в сървъри (guilds) и настройки
- Сесии за автентикация и информация за сигурността
Данните са организирани в JSON формат за по-лесно обработване и анализ.
Ако имате въпроси относно експорта на данните си, моля, свържете се с support@fluxer.app
- Екипът на Fluxer`,
},
unbanNotification: {
subject: 'Спирането на вашия Fluxer акаунт беше отменено',
body: `Здравей, {username},
Добри новини! Спирането на вашия Fluxer акаунт беше отменено.
Причина: {reason}
Сега можете отново да влезете в акаунта си и да продължите да използвате Fluxer.
- Екипът по безопасността на Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Вашият Fluxer акаунт е планиран за изтриване',
body: `Здравей, {username},
Вашият Fluxer акаунт е планиран за постоянно изтриване.
Планирана дата за изтриване: {deletionDate, date, full} {deletionDate, time, short}
Причина: {reason}
Това е сериозна мярка за налагане на правила. Данните от акаунта ви ще бъдат окончателно изтрити на посочената дата.
Ако смятате, че това решение за налагане на мярка е неправилно, можете да подадете жалба на appeals@fluxer.app от този имейл адрес.
- Екипът по безопасността на Fluxer`,
},
giftChargebackNotification: {
subject: 'Вашият Fluxer Premium подарък беше отменен',
body: `Здравей, {username},
Пишем ви, за да ви информираме, че Fluxer Premium подаръкът, който осребрихте, беше отменен поради платежен спор (chargeback), подаден от първоначалния купувач.
Премиум предимствата бяха премахнати от акаунта ви. Това действие беше предприето, защото плащането за подаръка беше оспорено и върнато.
Ако имате въпроси относно това, моля, свържете се с support@fluxer.app.
- Екипът на Fluxer`,
},
reportResolved: {
subject: 'Вашият Fluxer доклад беше прегледан',
body: `Здравей, {username},
Вашият доклад (ID: {reportId}) беше прегледан от нашия Екип по безопасността.
Отговор от Екипа по безопасността:
{publicComment}
Благодарим ви, че помагате да поддържаме Fluxer като безопасно място за всички. Вземаме всички доклади на сериозно и ценим вашия принос към нашата общност.
Ако имате въпроси или притеснения относно това решение, моля, свържете се с safety@fluxer.app.
- Екипът по безопасността на Fluxer`,
},
dsaReportVerification: {
subject: 'Потвърдете имейла си за DSA доклад',
body: `Здравейте,
Използвайте следния код за потвърждение, за да подадете доклад по Закона за цифровите услуги във Fluxer:
{code}
Този код ще изтече на {expiresAt, date, full} {expiresAt, time, short}.
Ако не сте поискали това, моля, игнорирайте този имейл.
- Екипът по безопасността на Fluxer`,
},
registrationApproved: {
subject: 'Вашата регистрация в Fluxer беше одобрена',
body: `Здравей, {username},
Чудесни новини! Вашата регистрация в Fluxer беше одобрена.
Можете вече да влезете в приложението Fluxer на:
{channelsUrl}
Добре дошли в общността на Fluxer!
- Екипът на Fluxer`,
},
emailChangeRevert: {
subject: 'Твоят имейл в Fluxer беше променен',
body: `Здравей, {username},
Имейлът на твоя акаунт в Fluxer беше променен на {newEmail}.
Ако ти направи тази промяна, не е нужно да правиш нищо. Ако не, можеш да я отмениш и да защитиш акаунта си чрез този линк:
{revertUrl}
Това ще възстанови предишния ти имейл, ще те отпише от всички сесии, ще премахне свързаните телефонни номера, ще деактивира MFA и ще изиска нова парола.
- Екипът по сигурността на Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const cs: EmailTranslations = {
passwordReset: {
subject: 'Obnovení hesla k účtu Fluxer',
body: `Dobrý den, {username},
požádali jste o obnovení hesla k vašemu účtu Fluxer. Prosím, klikněte na odkaz níže a nastavte si nové heslo:
{resetUrl}
Pokud jste o obnovení hesla nežádali, můžete tento e-mail bezpečně ignorovat.
Tento odkaz vyprší za 1 hodinu.
Tým Fluxer`,
},
emailVerification: {
subject: 'Ověřte svou e-mailovou adresu pro Fluxer',
body: `Dobrý den, {username},
prosíme, ověřte svoji e-mailovou adresu pro účet Fluxer kliknutím na odkaz níže:
{verifyUrl}
Pokud jste si účet Fluxer nevytvořili vy, můžete tento e-mail bezpečně ignorovat.
Tento odkaz vyprší za 24 hodin.
Tým Fluxer`,
},
ipAuthorization: {
subject: 'Povolte přihlášení z nové IP adresy',
body: `Dobrý den, {username},
zaznamenali jsme pokus o přihlášení k vašemu účtu Fluxer z nové IP adresy:
IP adresa: {ipAddress}
Místo: {location}
Pokud jste to byli vy, prosím, povolte tuto IP adresu kliknutím na odkaz níže:
{authUrl}
Pokud jste se přihlásit nepokoušeli, ihned si prosím změňte heslo.
Tento autorizační odkaz vyprší za 30 minut.
Tým Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Váš účet Fluxer byl dočasně deaktivován',
body: `Dobrý den, {username},
váš účet Fluxer byl dočasně deaktivován z důvodu podezřelé aktivity.
{reason, select,
null {}
other {Důvod: {reason}
}}Abyste znovu získali přístup ke svému účtu, musíte si obnovit heslo:
{forgotUrl}
Po obnovení hesla se budete moci znovu přihlásit.
Pokud se domníváte, že k tomuto kroku došlo omylem, obraťte se prosím na náš tým podpory.
Bezpečnostní tým Fluxer`,
},
accountTempBanned: {
subject: 'Váš účet Fluxer byl dočasně pozastaven',
body: `Dobrý den, {username},
váš účet Fluxer byl dočasně pozastaven kvůli porušení našich Smluvních podmínek nebo Pravidel komunity.
Doba trvání: {durationHours, plural,
=1 {1 hodina}
other {# hodin}
}
Pozastaveno do: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Důvod: {reason}}
}
Během této doby nebudete mít ke svému účtu přístup.
Doporučujeme vám prostudovat naše:
- Smluvní podmínky: {termsUrl}
- Pravidla komunity: {guidelinesUrl}
Pokud se domníváte, že toto rozhodnutí o vynucení pravidel je nesprávné nebo neopodstatněné, můžete podat odvolání na adresu appeals@fluxer.app z této e-mailové adresy. Prosím, jasně vysvětlete, proč si myslíte, že rozhodnutí bylo chybné. Vaše odvolání přezkoumáme a odpovíme vám s naším závěrem.
Bezpečnostní tým Fluxer`,
},
accountScheduledDeletion: {
subject: 'Váš účet Fluxer je naplánován k odstranění',
body: `Dobrý den, {username},
váš účet Fluxer byl naplánován k trvalému odstranění z důvodu porušení našich Smluvních podmínek nebo Pravidel komunity.
Plánované datum odstranění: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Důvod: {reason}}
}
Jedná se o závažné vynucovací opatření. Vaše data budou v plánovaném termínu trvale smazána.
Doporučujeme vám prostudovat naše:
- Smluvní podmínky: {termsUrl}
- Pravidla komunity: {guidelinesUrl}
PROCES ODVOlÁNÍ:
Pokud se domníváte, že toto rozhodnutí o vynucení pravidel je nesprávné nebo neopodstatněné, máte 30 dní na podání odvolání na adresu appeals@fluxer.app z této e-mailové adresy.
Ve svém odvolání prosím:
- Jasně vysvětlete, proč si myslíte, že rozhodnutí je nesprávné nebo neopodstatněné
- Uveďte všechny relevantní důkazy nebo kontext
Člen bezpečnostního týmu Fluxer vaše odvolání přezkoumá a může dočasně pozastavit plánované odstranění, dokud nebude vydáno konečné rozhodnutí.
Bezpečnostní tým Fluxer`,
},
selfDeletionScheduled: {
subject: 'Odstranění vašeho účtu Fluxer bylo naplánováno',
body: `Dobrý den, {username},
mrzí nás, že odcházíte! Odstranění vašeho účtu Fluxer bylo naplánováno.
Plánované datum odstranění: {deletionDate, date, full} {deletionDate, time, short}
DŮLEŽITÉ: Odstranění můžete kdykoli před {deletionDate, date, full} {deletionDate, time, short} zrušit jednoduše tím, že se znovu přihlásíte ke svému účtu.
NEŽ ODEJDETE:
Panel ochrany soukromí v nastavení uživatele vám umožňuje:
- Smazat vaše zprávy na platformě
- Exportovat si důležitá data před odchodem
Vezměte prosím na vědomí: Jakmile bude váš účet odstraněn, nebude již možné vaše zprávy smazat. Pokud chcete své zprávy odstranit, proveďte to prosím přes Panel ochrany soukromí před definitivním smazáním účtu.
Pokud si to rozmyslíte, stačí se znovu přihlásit a odstranění zrušit.
Tým Fluxer`,
},
inactivityWarning: {
subject: 'Váš účet Fluxer bude odstraněn kvůli neaktivitě',
body: `Dobrý den, {username},
všimli jsme si, že jste se ke svému účtu Fluxer nepřihlásili déle než 2 roky.
Poslední přihlášení: {lastActiveDate, date, full} {lastActiveDate, time, short}
V rámci naší politiky uchovávání dat jsou neaktivní účty automaticky naplánovány k odstranění. Váš účet bude trvale odstraněn dne:
Plánované datum odstranění: {deletionDate, date, full} {deletionDate, time, short}
JAK ZACHOVAT SVŮJ ÚČET:
Stačí se před datem odstranění přihlásit ke svému účtu na {loginUrl}. Není potřeba dělat nic dalšího.
CO SE STANE, POKUD SE NEPŘIHLÁSÍTE:
- Váš účet a všechna související data budou trvale odstraněna
- Vaše zprávy budou anonymizovány (přiřazeny uživateli „Smazaný uživatel“)
- Tento krok je nevratný
CHCETE SMAZAT SVÉ ZPRÁVY?
Pokud chcete své zprávy odstranit ještě před smazáním účtu, přihlaste se prosím a použijte Panel ochrany soukromí v nastavení uživatele.
Budeme rádi, pokud se na Fluxer vrátíte!
Tým Fluxer`,
},
harvestCompleted: {
subject: 'Váš export dat z Fluxer je připraven',
body: `Dobrý den, {username},
váš export dat byl dokončen a je připraven ke stažení!
Souhrn exportu:
- Celkový počet zpráv: {totalMessages, number}
- Velikost souboru: {fileSizeMB} MB
- Formát: ZIP archiv se soubory JSON
Stáhnout data: {downloadUrl}
DŮLEŽITÉ: Tento odkaz ke stažení vyprší {expiresAt, date, full} {expiresAt, time, short}
Co je součástí exportu:
- Všechny vaše zprávy uspořádané podle kanálů
- Metadat­a kanálů
- Váš uživatelský profil a informace o účtu
- Členství v guildách a nastavení
- Relace přihlášení a bezpečnostní informace
Data jsou organizována ve formátu JSON pro snadné zpracování a analýzu.
Pokud máte k exportu dat jakékoli dotazy, kontaktujte prosím support@fluxer.app
Tým Fluxer`,
},
unbanNotification: {
subject: 'Pozastavení vašeho účtu Fluxer bylo zrušeno',
body: `Dobrý den, {username},
dobrá zpráva! Pozastavení vašeho účtu Fluxer bylo zrušeno.
Důvod: {reason}
Nyní se můžete znovu přihlásit ke svému účtu a pokračovat v používání Fluxer.
Bezpečnostní tým Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Váš účet Fluxer je naplánován k odstranění',
body: `Dobrý den, {username},
váš účet Fluxer byl naplánován k trvalému odstranění.
Plánované datum odstranění: {deletionDate, date, full} {deletionDate, time, short}
Důvod: {reason}
Jedná se o závažné vynucovací opatření. Data vašeho účtu budou v plánovaném termínu trvale odstraněna.
Pokud se domníváte, že toto rozhodnutí je nesprávné, můžete podat odvolání na adresu appeals@fluxer.app z této e-mailové adresy.
Bezpečnostní tým Fluxer`,
},
giftChargebackNotification: {
subject: 'Váš darovaný Fluxer Premium byl zrušen',
body: `Dobrý den, {username},
chtěli bychom vás informovat, že darovaný Fluxer Premium, který jste uplatnili, byl zrušen z důvodu platebního sporu (chargeback) zahájeného původním plátcem.
Prémiové výhody byly z vašeho účtu odebrány. K tomuto kroku došlo proto, že platba za dárek byla napadena a vrácena.
Pokud k tomu máte jakékoli dotazy, kontaktujte prosím support@fluxer.app.
Tým Fluxer`,
},
reportResolved: {
subject: 'Vaše nahlášení na Fluxer bylo posouzeno',
body: `Dobrý den, {username},
vaše nahlášení (ID: {reportId}) bylo posouzeno naším Bezpečnostním týmem.
Odpověď Bezpečnostního týmu:
{publicComment}
Děkujeme, že pomáháte udržovat Fluxer bezpečným pro všechny. Všechna nahlášení bereme vážně a velmi si vážíme vašeho přínosu pro naši komunitu.
Pokud máte k tomuto rozhodnutí jakékoli dotazy nebo připomínky, kontaktujte prosím safety@fluxer.app.
Bezpečnostní tým Fluxer`,
},
dsaReportVerification: {
subject: 'Ověřte svůj e-mail pro nahlášení DSA',
body: `Dobrý den,
použijte následující ověřovací kód k odeslání nahlášení podle Zákona o digitálních službách na Fluxer:
{code}
Tento kód vyprší {expiresAt, date, full} {expiresAt, time, short}.
Pokud jste o toto nepožádali, můžete tento e-mail ignorovat.
Bezpečnostní tým Fluxer`,
},
registrationApproved: {
subject: 'Vaše registrace na Fluxer byla schválena',
body: `Dobrý den, {username},
skvělé zprávy! Vaše registrace na Fluxer byla schválena.
Nyní se můžete přihlásit do aplikace Fluxer na:
{channelsUrl}
Vítejte v komunitě Fluxer!
Tým Fluxer`,
},
emailChangeRevert: {
subject: 'Tvůj e-mail pro Fluxer byl změněn',
body: `Ahoj {username},
E-mail tvého účtu Fluxer byl změněn na {newEmail}.
Pokud jsi změnu udělal(a) ty, nic dalšího není potřeba. Pokud ne, můžeš ji vrátit zpět a zabezpečit účet pomocí tohoto odkazu:
{revertUrl}
Tím se obnoví tvůj původní e-mail, odhlásíš se všude, odstraní se propojená telefonní čísla, vypne se MFA a bude nutné nastavit nové heslo.
- Tým zabezpečení Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const da: EmailTranslations = {
passwordReset: {
subject: 'Nulstil din Fluxer-adgangskode',
body: `Hej {username},
Du har anmodet om at nulstille adgangskoden til din Fluxer-konto. Følg venligst linket nedenfor for at vælge en ny adgangskode:
{resetUrl}
Hvis du ikke har anmodet om at nulstille adgangskoden, kan du roligt ignorere denne e-mail.
Dette link udløber om 1 time.
Fluxer-teamet`,
},
emailVerification: {
subject: 'Bekræft din e-mailadresse til Fluxer',
body: `Hej {username},
Bekræft venligst din e-mailadresse til din Fluxer-konto ved at klikke på linket nedenfor:
{verifyUrl}
Hvis du ikke har oprettet en Fluxer-konto, kan du roligt ignorere denne e-mail.
Dette link udløber om 24 timer.
Fluxer-teamet`,
},
ipAuthorization: {
subject: 'Godkend login fra ny IP-adresse',
body: `Hej {username},
Vi har registreret et loginforsøg på din Fluxer-konto fra en ny IP-adresse:
IP-adresse: {ipAddress}
Placering: {location}
Hvis det var dig, skal du godkende denne IP-adresse ved at klikke på linket nedenfor:
{authUrl}
Hvis du ikke forsøgte at logge ind, bør du straks ændre din adgangskode.
Dette godkendelseslink udløber om 30 minutter.
Fluxer-teamet`,
},
accountDisabledSuspicious: {
subject: 'Din Fluxer-konto er midlertidigt deaktiveret',
body: `Hej {username},
Din Fluxer-konto er midlertidigt blevet deaktiveret på grund af mistænkelig aktivitet.
{reason, select,
null {}
other {Årsag: {reason}
}}For at få adgang til din konto igen skal du nulstille din adgangskode:
{forgotUrl}
Når du har nulstillet din adgangskode, kan du logge ind igen.
Hvis du mener, at denne handling er foretaget ved en fejl, bedes du kontakte vores supportteam.
Fluxer-sikkerhedsteamet`,
},
accountTempBanned: {
subject: 'Din Fluxer-konto er midlertidigt suspenderet',
body: `Hej {username},
Din Fluxer-konto er midlertidigt suspenderet for overtrædelse af vores servicevilkår eller fællesskabsretningslinjer.
Varighed: {durationHours, plural,
=1 {1 time}
other {# timer}
}
Suspenderet til: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Årsag: {reason}}
}
I denne periode vil du ikke kunne få adgang til din konto.
Vi anbefaler, at du gennemgår vores:
- Servicevilkår: {termsUrl}
- Fællesskabsretningslinjer: {guidelinesUrl}
Hvis du mener, at denne afgørelse er forkert eller uberettiget, kan du indsende en klage til appeals@fluxer.app fra denne e-mailadresse. Forklar venligst tydeligt, hvorfor du mener, at afgørelsen var forkert. Vi vil gennemgå din klage og vende tilbage med vores afgørelse.
Fluxer-sikkerhedsteamet`,
},
accountScheduledDeletion: {
subject: 'Din Fluxer-konto er planlagt til sletning',
body: `Hej {username},
Din Fluxer-konto er blevet planlagt til permanent sletning på grund af overtrædelser af vores servicevilkår eller fællesskabsretningslinjer.
Planlagt sletningsdato: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Årsag: {reason}}
}
Dette er en alvorlig håndhævelsesforanstaltning. Dine kontodata vil blive slettet permanent på den planlagte dato.
Vi anbefaler, at du gennemgår vores:
- Servicevilkår: {termsUrl}
- Fællesskabsretningslinjer: {guidelinesUrl}
KLAGEPROCES:
Hvis du mener, at denne håndhævelsesbeslutning er forkert eller uberettiget, har du 30 dage til at indsende en klage til appeals@fluxer.app fra denne e-mailadresse.
I din klage bør du:
- Tydeligt forklare, hvorfor du mener, at beslutningen er forkert eller uberettiget
- Give relevant dokumentation eller kontekst
Et medlem af Fluxer-sikkerhedsteamet vil gennemgå din klage og kan midlertidigt sætte den planlagte sletning på pause, indtil der er truffet en endelig afgørelse.
Fluxer-sikkerhedsteamet`,
},
selfDeletionScheduled: {
subject: 'Sletning af din Fluxer-konto er planlagt',
body: `Hej {username},
Vi er kede af at se dig gå! Sletning af din Fluxer-konto er blevet planlagt.
Planlagt sletningsdato: {deletionDate, date, full} {deletionDate, time, short}
VIGTIGT: Du kan til enhver tid annullere denne sletning inden {deletionDate, date, full} {deletionDate, time, short} ved blot at logge ind på din konto igen.
FØR DU GÅR:
Dit privatlivskontrolcenter i brugerindstillingerne giver dig mulighed for at:
- Slette dine beskeder på platformen
- Eksportere vigtige data, før du forlader tjenesten
Bemærk: Når din konto først er blevet slettet, er det ikke længere muligt at slette dine beskeder. Hvis du ønsker at slette dine beskeder, skal du gøre det via privatlivskontrolcenteret, inden kontosletningen fuldføres.
Hvis du ændrer mening, skal du blot logge ind igen for at annullere sletningen.
Fluxer-teamet`,
},
inactivityWarning: {
subject: 'Din Fluxer-konto bliver slettet på grund af inaktivitet',
body: `Hej {username},
Vi har bemærket, at du ikke har logget ind på din Fluxer-konto i over 2 år.
Seneste login: {lastActiveDate, date, full} {lastActiveDate, time, short}
Som en del af vores politik for opbevaring af data bliver inaktive konti automatisk planlagt til sletning. Din konto vil blive permanent slettet på:
Planlagt sletningsdato: {deletionDate, date, full} {deletionDate, time, short}
SÅDAN BEHOLDER DU DIN KONTO:
Du skal blot logge ind på din konto på {loginUrl} før sletningsdatoen for at annullere denne automatiske sletning. Der kræves ingen yderligere handling.
HVAD SKER DER, HVIS DU IKKE LOGGER IND:
- Din konto og alle tilknyttede data vil blive slettet permanent
- Dine beskeder vil blive anonymiseret (tilskrevet “Slettet bruger”)
- Denne handling kan ikke fortrydes
VIL DU SLETTE DINE BESKEDER?
Hvis du ønsker at slette dine beskeder, inden din konto slettes, skal du logge ind og bruge privatlivskontrolcenteret i brugerindstillingerne.
Vi håber at se dig tilbage på Fluxer!
Fluxer-teamet`,
},
harvestCompleted: {
subject: 'Din Fluxer-dataeksport er klar',
body: `Hej {username},
Din dataeksport er fuldført og er klar til download!
Eksportsammendrag:
- Samlet antal beskeder: {totalMessages, number}
- Filstørrelse: {fileSizeMB} MB
- Format: ZIP-arkiv med JSON-filer
Download dine data: {downloadUrl}
VIGTIGT: Dette downloadlink udløber den {expiresAt, date, full} {expiresAt, time, short}
Hvad er inkluderet i eksporten:
- Alle dine beskeder organiseret efter kanal
- Kanalmetadata
- Din brugerprofil og kontooplysninger
- Guild-medlemskaber og indstillinger
- Godkendelsessessioner og sikkerhedsoplysninger
Dataene er organiseret i JSON-format for nem parsing og analyse.
Hvis du har spørgsmål til din dataeksport, kan du kontakte support@fluxer.app
Fluxer-teamet`,
},
unbanNotification: {
subject: 'Suspenderingen af din Fluxer-konto er ophævet',
body: `Hej {username},
Gode nyheder! Suspenderingen af din Fluxer-konto er blevet ophævet.
Årsag: {reason}
Du kan nu logge ind på din konto igen og fortsætte med at bruge Fluxer.
Fluxer-sikkerhedsteamet`,
},
scheduledDeletionNotification: {
subject: 'Din Fluxer-konto er planlagt til sletning',
body: `Hej {username},
Din Fluxer-konto er blevet planlagt til permanent sletning.
Planlagt sletningsdato: {deletionDate, date, full} {deletionDate, time, short}
Årsag: {reason}
Dette er en alvorlig håndhævelsesforanstaltning. Dine kontodata vil blive slettet permanent på den planlagte dato.
Hvis du mener, at denne beslutning er forkert, kan du indsende en klage til appeals@fluxer.app fra denne e-mailadresse.
Fluxer-sikkerhedsteamet`,
},
giftChargebackNotification: {
subject: 'Din Fluxer Premium-gave er blevet tilbagekaldt',
body: `Hej {username},
Vi skriver for at informere dig om, at den Fluxer Premium-gave, du har indløst, er blevet tilbagekaldt på grund af en betalingstvist (chargeback), som den oprindelige køber har rejst.
Dine premiumfordele er blevet fjernet fra din konto. Denne handling blev foretaget, fordi betalingen for gaven blev omstødt.
Hvis du har spørgsmål til dette, kan du kontakte support@fluxer.app.
Fluxer-teamet`,
},
reportResolved: {
subject: 'Din Fluxer-rapport er blevet gennemgået',
body: `Hej {username},
Din rapport (ID: {reportId}) er blevet gennemgået af vores sikkerhedsteam.
Svar fra sikkerhedsteamet:
{publicComment}
Tak fordi du hjælper med at holde Fluxer sikkert for alle. Vi tager alle rapporter alvorligt og værdsætter dit bidrag til vores fællesskab.
Hvis du har spørgsmål eller bekymringer vedrørende denne afgørelse, kan du kontakte safety@fluxer.app.
Fluxer-sikkerhedsteamet`,
},
dsaReportVerification: {
subject: 'Bekræft din e-mail til en DSA-rapport',
body: `Hej,
Brug følgende verifikationskode til at indsende din rapport i henhold til loven om digitale tjenester på Fluxer:
{code}
Denne kode udløber den {expiresAt, date, full} {expiresAt, time, short}.
Hvis du ikke har anmodet om dette, kan du roligt ignorere denne e-mail.
Fluxer-sikkerhedsteamet`,
},
registrationApproved: {
subject: 'Din Fluxer-registrering er godkendt',
body: `Hej {username},
Gode nyheder! Din registrering på Fluxer er blevet godkendt.
Du kan nu logge ind i Fluxer-appen på:
{channelsUrl}
Velkommen til Fluxer-fællesskabet!
Fluxer-teamet`,
},
emailChangeRevert: {
subject: 'Din Fluxer-e-mail er blevet ændret',
body: `Hej {username},
E-mailen for din Fluxer-konto er blevet ændret til {newEmail}.
Hvis du foretog ændringen, behøver du ikke gøre mere. Hvis ikke, kan du fortryde og sikre kontoen via dette link:
{revertUrl}
Det gendanner din tidligere e-mail, logger dig ud alle steder, fjerner tilknyttede telefonnumre, deaktiverer MFA og kræver en ny adgangskode.
- Fluxer Sikkerhedsteam`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const de: EmailTranslations = {
passwordReset: {
subject: 'Setze dein Fluxer-Passwort zurück',
body: `Hallo {username},
du hast angefordert, das Passwort für dein Fluxer-Konto zurückzusetzen. Bitte folge dem Link unten, um ein neues Passwort festzulegen:
{resetUrl}
Wenn du diese Zurücksetzung nicht angefordert hast, kannst du diese E-Mail sicher ignorieren.
Dieser Link läuft in 1 Stunde ab.
- Dein Fluxer-Team`,
},
emailVerification: {
subject: 'Bestätige deine Fluxer-E-Mail-Adresse',
body: `Hallo {username},
bitte bestätige deine E-Mail-Adresse für dein Fluxer-Konto, indem du auf den folgenden Link klickst:
{verifyUrl}
Wenn du kein Fluxer-Konto erstellt hast, kannst du diese E-Mail sicher ignorieren.
Dieser Link läuft in 24 Stunden ab.
- Dein Fluxer-Team`,
},
ipAuthorization: {
subject: 'Login von neuer IP-Adresse autorisieren',
body: `Hallo {username},
wir haben einen Anmeldeversuch bei deinem Fluxer-Konto von einer neuen IP-Adresse festgestellt:
IP-Adresse: {ipAddress}
Ort: {location}
Wenn du das warst, autorisiere diese IP-Adresse bitte über den folgenden Link:
{authUrl}
Wenn du nicht versucht hast, dich anzumelden, ändere bitte umgehend dein Passwort.
Dieser Autorisierungslink läuft in 30 Minuten ab.
- Dein Fluxer-Team`,
},
accountDisabledSuspicious: {
subject: 'Dein Fluxer-Konto wurde vorübergehend deaktiviert',
body: `Hallo {username},
dein Fluxer-Konto wurde aufgrund verdächtiger Aktivitäten vorübergehend deaktiviert.
{reason, select,
null {}
other {Grund: {reason}
}}Um den Zugriff auf dein Konto wiederzuerlangen, musst du dein Passwort zurücksetzen:
{forgotUrl}
Nachdem du dein Passwort zurückgesetzt hast, kannst du dich wieder anmelden.
Wenn du glaubst, dass diese Maßnahme irrtümlich erfolgt ist, kontaktiere bitte unser Support-Team.
- Fluxer Safety Team`,
},
accountTempBanned: {
subject: 'Dein Fluxer-Konto wurde vorübergehend gesperrt',
body: `Hallo {username},
dein Fluxer-Konto wurde wegen Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien vorübergehend gesperrt.
Dauer: {durationHours, plural,
=1 {1 Stunde}
other {# Stunden}
}
Gesperrt bis: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Grund: {reason}}
}
In dieser Zeit hast du keinen Zugriff auf dein Konto.
Wir empfehlen dir, unsere folgenden Dokumente zu überprüfen:
- Nutzungsbedingungen: {termsUrl}
- Community-Richtlinien: {guidelinesUrl}
Wenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt ist, kannst du eine Beschwerde an appeals@fluxer.app von dieser E-Mail-Adresse senden. Bitte erkläre klar und ausführlich, warum du glaubst, dass die Entscheidung falsch war. Wir werden deine Beschwerde prüfen und dir unsere Entscheidung mitteilen.
- Fluxer Safety Team`,
},
accountScheduledDeletion: {
subject: 'Dein Fluxer-Konto ist zur Löschung vorgesehen',
body: `Hallo {username},
dein Fluxer-Konto wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgesehen.
Geplantes Löschdatum: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Grund: {reason}}
}
Dies ist eine schwerwiegende Vollzugsmaßnahme. Deine Kontodaten werden am geplanten Datum dauerhaft gelöscht.
Wir empfehlen dir, unsere folgenden Dokumente zu überprüfen:
- Nutzungsbedingungen: {termsUrl}
- Community-Richtlinien: {guidelinesUrl}
EINSICHTS- UND BESCHWERDEVERFAHREN:
Wenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt ist, hast du 30 Tage Zeit, eine Beschwerde an appeals@fluxer.app von dieser E-Mail-Adresse zu senden.
In deiner Beschwerde solltest du:
- Klar erläutern, warum du die Entscheidung für falsch oder ungerechtfertigt hältst
- Alle relevanten Belege oder Kontextinformationen anführen
Ein Mitglied des Fluxer Safety Teams wird deine Beschwerde prüfen und die geplante Löschung gegebenenfalls aussetzen, bis eine endgültige Entscheidung getroffen wurde.
- Fluxer Safety Team`,
},
selfDeletionScheduled: {
subject: 'Die Löschung deines Fluxer-Kontos wurde geplant',
body: `Hallo {username},
es tut uns leid, dass du gehst! Die Löschung deines Fluxer-Kontos wurde geplant.
Geplantes Löschdatum: {deletionDate, date, full} {deletionDate, time, short}
WICHTIG: Du kannst diese Löschung jederzeit vor {deletionDate, date, full} {deletionDate, time, short} widerrufen, indem du dich einfach wieder in dein Konto einloggst.
BEVOR DU GEHST:
Dein Datenschutz-Dashboard in den Benutzereinstellungen ermöglicht dir:
- Deine Nachrichten auf der Plattform zu löschen
- Wichtige Daten vor deinem Abschied zu exportieren
Bitte beachte: Sobald dein Konto gelöscht wurde, gibt es keine Möglichkeit mehr, deine Nachrichten zu löschen. Wenn du deine Nachrichten entfernen möchtest, tue dies bitte über das Datenschutz-Dashboard, bevor die Kontolöschung abgeschlossen ist.
Wenn du deine Meinung änderst, logge dich einfach wieder ein, um die Löschung zu stornieren.
- Dein Fluxer-Team`,
},
inactivityWarning: {
subject: 'Dein Fluxer-Konto wird wegen Inaktivität gelöscht',
body: `Hallo {username},
wir haben festgestellt, dass du dich seit über 2 Jahren nicht mehr in dein Fluxer-Konto eingeloggt hast.
Letzte Anmeldung: {lastActiveDate, date, full} {lastActiveDate, time, short}
Im Rahmen unserer Richtlinie zur Datenspeicherung werden inaktive Konten automatisch zur Löschung vorgemerkt. Dein Konto wird dauerhaft gelöscht am:
Geplantes Löschdatum: {deletionDate, date, full} {deletionDate, time, short}
SO BEHÄLTST DU DEIN KONTO:
Logge dich einfach vor dem Löschdatum unter {loginUrl} in dein Konto ein, um diese automatische Löschung zu verhindern. Weitere Schritte sind nicht erforderlich.
WAS PASSIERT, WENN DU DICH NICHT EINLOGGST:
- Dein Konto und alle zugehörigen Daten werden dauerhaft gelöscht
- Deine Nachrichten werden anonymisiert (als „Gelöschter Benutzer“ gekennzeichnet)
- Diese Aktion kann nicht rückgängig gemacht werden
MÖCHTEST DU DEINE NACHRICHTEN LÖSCHEN?
Wenn du deine Nachrichten löschen möchtest, bevor dein Konto gelöscht wird, logge dich bitte ein und nutze das Datenschutz-Dashboard in den Benutzereinstellungen.
Wir würden uns freuen, dich wieder bei Fluxer zu sehen!
- Dein Fluxer-Team`,
},
harvestCompleted: {
subject: 'Dein Fluxer-Datenexport ist bereit',
body: `Hallo {username},
dein Datenexport wurde abgeschlossen und steht jetzt zum Download bereit!
Exportübersicht:
- Gesamte Anzahl an Nachrichten: {totalMessages, number}
- Dateigröße: {fileSizeMB} MB
- Format: ZIP-Archiv mit JSON-Dateien
Lade deine Daten herunter: {downloadUrl}
WICHTIG: Dieser Download-Link läuft am {expiresAt, date, full} {expiresAt, time, short} ab.
Folgendes ist in deinem Export enthalten:
- Alle deine Nachrichten, nach Kanälen organisiert
- Kanal-Metadaten
- Dein Benutzerprofil und Kontoinformationen
- Guild-Mitgliedschaften und Einstellungen
- Anmeldesitzungen und Sicherheitsinformationen
Die Daten sind im JSON-Format organisiert, um die Verarbeitung und Analyse zu erleichtern.
Wenn du Fragen zu deinem Datenexport hast, kontaktiere bitte support@fluxer.app
- Dein Fluxer-Team`,
},
unbanNotification: {
subject: 'Die Sperre deines Fluxer-Kontos wurde aufgehoben',
body: `Hallo {username},
gute Nachrichten! Die Sperre deines Fluxer-Kontos wurde aufgehoben.
Grund: {reason}
Du kannst dich jetzt wieder in dein Konto einloggen und Fluxer weiter nutzen.
- Fluxer Safety Team`,
},
scheduledDeletionNotification: {
subject: 'Dein Fluxer-Konto ist zur Löschung vorgemerkt',
body: `Hallo {username},
dein Fluxer-Konto wurde zur dauerhaften Löschung vorgemerkt.
Geplantes Löschdatum: {deletionDate, date, full} {deletionDate, time, short}
Grund: {reason}
Dies ist eine schwerwiegende Vollzugsmaßnahme. Deine Kontodaten werden am geplanten Datum dauerhaft gelöscht.
Wenn du glaubst, dass diese Entscheidung falsch ist, kannst du eine Beschwerde an appeals@fluxer.app von dieser E-Mail-Adresse senden.
- Fluxer Safety Team`,
},
giftChargebackNotification: {
subject: 'Dein Fluxer Premium-Geschenk wurde widerrufen',
body: `Hallo {username},
wir möchten dich darüber informieren, dass das Fluxer Premium-Geschenk, das du eingelöst hast, aufgrund eines Zahlungsstreits (Chargeback) des ursprünglichen Käufers widerrufen wurde.
Deine Premium-Vorteile wurden von deinem Konto entfernt. Diese Maßnahme wurde ergriffen, weil die Zahlung für das Geschenk angefochten und rückgängig gemacht wurde.
Wenn du Fragen dazu hast, kontaktiere bitte support@fluxer.app.
- Dein Fluxer-Team`,
},
reportResolved: {
subject: 'Deine Fluxer-Meldung wurde überprüft',
body: `Hallo {username},
deine Meldung (ID: {reportId}) wurde von unserem Safety Team geprüft.
Antwort vom Safety Team:
{publicComment}
Vielen Dank, dass du dabei hilfst, Fluxer für alle sicher zu halten. Wir nehmen alle Meldungen ernst und schätzen deinen Beitrag zu unserer Community.
Wenn du Fragen oder Bedenken hinsichtlich dieser Entscheidung hast, kontaktiere bitte safety@fluxer.app.
- Fluxer Safety Team`,
},
dsaReportVerification: {
subject: 'Bestätige deine E-Mail für eine DSA-Meldung',
body: `Hallo,
Verwende den folgenden Bestätigungscode, um deine Meldung gemäß dem Digital Services Act auf Fluxer einzureichen:
{code}
Dieser Code läuft ab am {expiresAt, date, full} {expiresAt, time, short}.
Wenn du dies nicht angefordert hast, kannst du diese E-Mail ignorieren.
- Fluxer Safety Team`,
},
registrationApproved: {
subject: 'Deine Fluxer-Registrierung wurde genehmigt',
body: `Hallo {username},
gute Nachrichten! Deine Registrierung bei Fluxer wurde genehmigt.
Du kannst dich jetzt in der Fluxer-App anmelden unter:
{channelsUrl}
Willkommen in der Fluxer-Community!
- Dein Fluxer-Team`,
},
emailChangeRevert: {
subject: 'Deine Fluxer-E-Mail wurde geändert',
body: `Hallo {username},
Die E-Mail deines Fluxer-Kontos wurde auf {newEmail} geändert.
Wenn du diese Änderung vorgenommen hast, musst du nichts weiter tun. Falls nicht, kannst du sie über diesen Link rückgängig machen und dein Konto sichern:
{revertUrl}
Dadurch wird deine vorherige E-Mail wiederhergestellt, du wirst überall abgemeldet, verknüpfte Telefonnummern werden entfernt, MFA wird deaktiviert und ein neues Passwort ist erforderlich.
- Fluxer Sicherheitsteam`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const el: EmailTranslations = {
passwordReset: {
subject: 'Επαναφορά κωδικού πρόσβασης Fluxer',
body: `Γεια σου {username},
Ζήτησες να επαναφέρεις τον κωδικό πρόσβασής σου στο Fluxer. Ακολούθησε τον παρακάτω σύνδεσμο για να ορίσεις νέο κωδικό:
{resetUrl}
Αν δεν ζήτησες εσύ αυτήν την επαναφορά κωδικού, μπορείς να αγνοήσεις αυτό το email με ασφάλεια.
Αυτός ο σύνδεσμος θα λήξει σε 1 ώρα.
- Ομάδα Fluxer`,
},
emailVerification: {
subject: 'Επαλήθευσε το email σου στο Fluxer',
body: `Γεια σου {username},
Παρακαλούμε επαλήθευσε τη διεύθυνση email του λογαριασμού σου στο Fluxer κάνοντας κλικ στον παρακάτω σύνδεσμο:
{verifyUrl}
Αν δεν δημιούργησες λογαριασμό Fluxer, μπορείς να αγνοήσεις αυτό το email με ασφάλεια.
Αυτός ο σύνδεσμος θα λήξει σε 24 ώρες.
- Ομάδα Fluxer`,
},
ipAuthorization: {
subject: 'Εξουσιοδότηση σύνδεσης από νέα διεύθυνση IP',
body: `Γεια σου {username},
Εντοπίσαμε προσπάθεια σύνδεσης στον λογαριασμό σου στο Fluxer από νέα διεύθυνση IP:
Διεύθυνση IP: {ipAddress}
Τοποθεσία: {location}
Αν ήσουν εσύ, εξουσιοδότησε αυτήν τη διεύθυνση IP κάνοντας κλικ στον παρακάτω σύνδεσμο:
{authUrl}
Αν δεν προσπάθησες να συνδεθείς, άλλαξε άμεσα τον κωδικό πρόσβασής σου.
Αυτός ο σύνδεσμος εξουσιοδότησης θα λήξει σε 30 λεπτά.
- Ομάδα Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Ο λογαριασμός σου στο Fluxer έχει απενεργοποιηθεί προσωρινά',
body: `Γεια σου {username},
Ο λογαριασμός σου στο Fluxer απενεργοποιήθηκε προσωρινά λόγω ύποπτης δραστηριότητας.
{reason, select,
null {}
other {Αιτία: {reason}
}}Για να αποκτήσεις ξανά πρόσβαση στον λογαριασμό σου, πρέπει να επαναφέρεις τον κωδικό πρόσβασης:
{forgotUrl}
Αφού επαναφέρεις τον κωδικό σου, θα μπορείς να συνδεθείς ξανά.
Αν πιστεύεις ότι αυτή η ενέργεια έγινε κατά λάθος, επικοινώνησε με την ομάδα υποστήριξής μας.
- Ομάδα Ασφάλειας του Fluxer`,
},
accountTempBanned: {
subject: 'Ο λογαριασμός σου στο Fluxer έχει ανασταλεί προσωρινά',
body: `Γεια σου {username},
Ο λογαριασμός σου στο Fluxer έχει ανασταλεί προσωρινά λόγω παραβίασης των Όρων Χρήσης ή των Οδηγιών Κοινότητας.
Διάρκεια: {durationHours, plural,
=1 {1 ώρα}
other {# ώρες}
}
Αναστολή έως: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Αιτία: {reason}}
}
Κατά τη διάρκεια αυτής της περιόδου δεν θα μπορείς να έχεις πρόσβαση στον λογαριασμό σου.
Συνιστούμε να διαβάσεις:
- Όρους Χρήσης: {termsUrl}
- Οδηγίες Κοινότητας: {guidelinesUrl}
Αν πιστεύεις ότι αυτή η απόφαση επιβολής ήταν λανθασμένη ή αδικαιολόγητη, μπορείς να υποβάλεις ένσταση στο appeals@fluxer.app από αυτή τη διεύθυνση email. Εξήγησε ξεκάθαρα γιατί θεωρείς ότι η απόφαση ήταν λανθασμένη. Θα εξετάσουμε την ένστασή σου και θα απαντήσουμε με την τελική μας απόφαση.
- Ομάδα Ασφάλειας του Fluxer`,
},
accountScheduledDeletion: {
subject: 'Ο λογαριασμός σου στο Fluxer έχει προγραμματιστεί για διαγραφή',
body: `Γεια σου {username},
Ο λογαριασμός σου στο Fluxer έχει προγραμματιστεί για οριστική διαγραφή λόγω παραβιάσεων των Όρων Χρήσης ή των Οδηγιών Κοινότητας.
Προγραμματισμένη ημερομηνία διαγραφής: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Αιτία: {reason}}
}
Πρόκειται για σοβαρό μέτρο επιβολής. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά στην προγραμματισμένη ημερομηνία.
Συνιστούμε να διαβάσεις:
- Όρους Χρήσης: {termsUrl}
- Οδηγίες Κοινότητας: {guidelinesUrl}
ΔΙΑΔΙΚΑΣΙΑ ΕΝΣΤΑΣΗΣ:
Αν πιστεύεις ότι αυτή η απόφαση επιβολής ήταν λανθασμένη ή αδικαιολόγητη, έχεις 30 ημέρες για να υποβάλεις ένσταση στο appeals@fluxer.app από αυτή τη διεύθυνση email.
Στην ένστασή σου:
- Εξήγησε με σαφήνεια γιατί θεωρείς ότι η απόφαση ήταν λανθασμένη ή αδικαιολόγητη
- Παρείχε τυχόν σχετικά στοιχεία ή επιπλέον πλαίσιο
Ένα μέλος της Ομάδας Ασφάλειας του Fluxer θα εξετάσει την ένστασή σου και μπορεί να αναστείλει την προγραμματισμένη διαγραφή μέχρι να ληφθεί τελική απόφαση.
- Ομάδα Ασφάλειας του Fluxer`,
},
selfDeletionScheduled: {
subject: 'Η διαγραφή του λογαριασμού σου στο Fluxer έχει προγραμματιστεί',
body: `Γεια σου {username},
Λυπούμαστε που σε βλέπουμε να φεύγεις! Η διαγραφή του λογαριασμού σου στο Fluxer έχει προγραμματιστεί.
Προγραμματισμένη ημερομηνία διαγραφής: {deletionDate, date, full} {deletionDate, time, short}
ΣΗΜΑΝΤΙΚΟ: Μπορείς να ακυρώσεις αυτή τη διαγραφή οποιαδήποτε στιγμή πριν από {deletionDate, date, full} {deletionDate, time, short} απλώς συνδεόμενος/η ξανά στον λογαριασμό σου.
ΠΡΙΝ ΦΥΓΕΙΣ:
Ο Πίνακας Ελέγχου Απορρήτου στις Ρυθμίσεις Χρήστη σου επιτρέπει να:
- Διαγράψεις τα μηνύματά σου στην πλατφόρμα
- Εξαγάγεις σημαντικά δεδομένα πριν αποχωρήσεις
Σημείωση: Μόλις διαγραφεί ο λογαριασμός σου, δεν θα υπάρχει τρόπος να διαγράψεις τα μηνύματά σου. Αν θέλεις να διαγράψεις τα μηνύματά σου, κάν' το μέσω του Πίνακα Ελέγχου Απορρήτου πριν ολοκληρωθεί η διαγραφή του λογαριασμού.
Αν αλλάξεις γνώμη, απλώς συνδέσου ξανά για να ακυρώσεις τη διαγραφή.
- Ομάδα Fluxer`,
},
inactivityWarning: {
subject: 'Ο λογαριασμός σου στο Fluxer θα διαγραφεί λόγω αδράνειας',
body: `Γεια σου {username},
Παρατηρήσαμε ότι δεν έχεις συνδεθεί στον λογαριασμό σου στο Fluxer για πάνω από 2 χρόνια.
Τελευταία σύνδεση: {lastActiveDate, date, full} {lastActiveDate, time, short}
Στο πλαίσιο της πολιτικής διατήρησης δεδομένων μας, οι ανενεργοί λογαριασμοί προγραμματίζονται αυτόματα για διαγραφή. Ο λογαριασμός σου θα διαγραφεί οριστικά στις:
Προγραμματισμένη ημερομηνία διαγραφής: {deletionDate, date, full} {deletionDate, time, short}
ΠΩΣ ΝΑ ΔΙΑΤΗΡΗΣΕΙΣ ΤΟΝ ΛΟΓΑΡΙΑΣΜΟ ΣΟΥ:
Αρκεί να συνδεθείς στον λογαριασμό σου στο {loginUrl} πριν από την ημερομηνία διαγραφής για να ακυρώσεις αυτή την αυτόματη διαγραφή. Δεν απαιτείται καμία άλλη ενέργεια.
ΤΙ ΣΥΜΒΑΙΝΕΙ ΑΝ ΔΕΝ ΣΥΝΔΕΘΕΙΣ:
- Ο λογαριασμός σου και όλα τα σχετικά δεδομένα θα διαγραφούν οριστικά
- Τα μηνύματά σου θα ανωνυμοποιηθούν (με την ένδειξη «Διαγραμμένος χρήστης»)
- Αυτή η ενέργεια δεν μπορεί να αναιρεθεί
ΘΕΛΕΙΣ ΝΑ ΔΙΑΓΡΑΨΕΙΣ ΤΑ ΜΗΝΥΜΑΤΑ ΣΟΥ;
Αν θέλεις να διαγράψεις τα μηνύματά σου πριν διαγραφεί ο λογαριασμός σου, συνδέσου και χρησιμοποίησε τον Πίνακα Ελέγχου Απορρήτου στις Ρυθμίσεις Χρήστη.
Ελπίζουμε να σε ξαναδούμε στο Fluxer!
- Ομάδα Fluxer`,
},
harvestCompleted: {
subject: 'Η εξαγωγή δεδομένων σου από το Fluxer είναι έτοιμη',
body: `Γεια σου {username},
Η εξαγωγή των δεδομένων σου ολοκληρώθηκε και είναι έτοιμη για λήψη!
Σύνοψη εξαγωγής:
- Συνολικός αριθμός μηνυμάτων: {totalMessages, number}
- Μέγεθος αρχείου: {fileSizeMB} MB
- Μορφή: Αρχείο ZIP με αρχεία JSON
Κάνε λήψη των δεδομένων σου: {downloadUrl}
ΣΗΜΑΝΤΙΚΟ: Αυτός ο σύνδεσμος λήψης θα λήξει στις {expiresAt, date, full} {expiresAt, time, short}
Τι περιλαμβάνει η εξαγωγή:
- Όλα τα μηνύματά σου, οργανωμένα ανά κανάλι
- Μεταδεδομένα καναλιών
- Το προφίλ χρήστη και οι πληροφορίες λογαριασμού σου
- Συμμετοχές σε guilds και ρυθμίσεις
- Συνεδρίες ταυτοποίησης και πληροφορίες ασφαλείας
Τα δεδομένα είναι οργανωμένα σε μορφή JSON για εύκολη επεξεργασία και ανάλυση.
Αν έχεις οποιαδήποτε απορία σχετικά με την εξαγωγή δεδομένων σου, επικοινώνησε με το support@fluxer.app
- Ομάδα Fluxer`,
},
unbanNotification: {
subject: 'Η αναστολή του λογαριασμού σου στο Fluxer άρθηκε',
body: `Γεια σου {username},
Καλά νέα! Η αναστολή του λογαριασμού σου στο Fluxer άρθηκε.
Αιτία: {reason}
Μπορείς τώρα να συνδεθείς ξανά στον λογαριασμό σου και να συνεχίσεις να χρησιμοποιείς το Fluxer.
- Ομάδα Ασφάλειας του Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Ο λογαριασμός σου στο Fluxer έχει προγραμματιστεί για διαγραφή',
body: `Γεια σου {username},
Ο λογαριασμός σου στο Fluxer έχει προγραμματιστεί για οριστική διαγραφή.
Προγραμματισμένη ημερομηνία διαγραφής: {deletionDate, date, full} {deletionDate, time, short}
Αιτία: {reason}
Πρόκειται για σοβαρό μέτρο επιβολής. Τα δεδομένα του λογαριασμού σου θα διαγραφούν οριστικά στην προγραμματισμένη ημερομηνία.
Αν πιστεύεις ότι αυτή η απόφαση ήταν εσφαλμένη, μπορείς να υποβάλεις ένσταση στο appeals@fluxer.app από αυτή τη διεύθυνση email.
- Ομάδα Ασφάλειας του Fluxer`,
},
giftChargebackNotification: {
subject: 'Το δώρο Fluxer Premium σου έχει ανακληθεί',
body: `Γεια σου {username},
Σε ενημερώνουμε ότι το δώρο Fluxer Premium που εξαργύρωσες έχει ανακληθεί λόγω διαφοράς πληρωμής (chargeback) που υπέβαλε ο αρχικός αγοραστής.
Τα Premium προνόμια αφαιρέθηκαν από τον λογαριασμό σου. Αυτή η ενέργεια έγινε επειδή η πληρωμή για το δώρο αμφισβητήθηκε και αντιστράφηκε.
Αν έχεις απορίες σχετικά με αυτό, επικοινώνησε με το support@fluxer.app.
- Ομάδα Fluxer`,
},
reportResolved: {
subject: 'Η αναφορά σου στο Fluxer έχει εξεταστεί',
body: `Γεια σου {username},
Η αναφορά σου (ID: {reportId}) εξετάστηκε από την Ομάδα Ασφάλειας μας.
Απάντηση από την Ομάδα Ασφάλειας:
{publicComment}
Σε ευχαριστούμε που βοηθάς να διατηρήσουμε το Fluxer ασφαλές για όλους. Λαμβάνουμε όλες τις αναφορές σοβαρά υπόψη και εκτιμούμε τη συνεισφορά σου στην κοινότητά μας.
Αν έχεις οποιαδήποτε ερώτηση ή ανησυχία σχετικά με αυτήν την απόφαση, επικοινώνησε με το safety@fluxer.app.
- Ομάδα Ασφάλειας του Fluxer`,
},
dsaReportVerification: {
subject: 'Επαλήθευσε το email σου για αναφορά DSA',
body: `Γεια σου,
Χρησιμοποίησε τον παρακάτω κωδικό επαλήθευσης για να υποβάλεις την αναφορά σου βάσει του Νόμου Ψηφιακών Υπηρεσιών στο Fluxer:
{code}
Αυτός ο κωδικός θα λήξει στις {expiresAt, date, full} {expiresAt, time, short}.
Αν δεν ζήτησες εσύ αυτό, μπορείς να αγνοήσεις αυτό το email.
- Ομάδα Ασφάλειας του Fluxer`,
},
registrationApproved: {
subject: 'Η εγγραφή σου στο Fluxer εγκρίθηκε',
body: `Γεια σου {username},
Υπέροχα νέα! Η εγγραφή σου στο Fluxer εγκρίθηκε.
Μπορείς τώρα να συνδεθείς στην εφαρμογή Fluxer στη διεύθυνση:
{channelsUrl}
Καλωσόρισες στην κοινότητα του Fluxer!
- Ομάδα Fluxer`,
},
emailChangeRevert: {
subject: 'Το email σου στο Fluxer άλλαξε',
body: `Γεια σου {username},
Το email του λογαριασμού σου στο Fluxer άλλαξε σε {newEmail}.
Αν έκανες εσύ αυτή την αλλαγή, δεν χρειάζεται να κάνεις κάτι άλλο. Διαφορετικά, μπορείς να την αναιρέσεις και να προστατεύσεις τον λογαριασμό σου μέσω αυτού του συνδέσμου:
{revertUrl}
Έτσι θα επανέλθει το προηγούμενο email σου, θα αποσυνδεθείς από όλες τις συνεδρίες, θα αφαιρεθούν τα συνδεδεμένα τηλέφωνα, θα απενεργοποιηθεί το MFA και θα χρειαστεί νέο συνθηματικό.
- Ομάδα Ασφαλείας Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const enGB: EmailTranslations = {
passwordReset: {
subject: 'Reset your Fluxer password',
body: `Hello {username},
You requested to reset your Fluxer password. Please follow the link below to set a new password:
{resetUrl}
If you did not request this password reset, you can safely ignore this email.
This link will expire in 1 hour.
- Fluxer Team`,
},
emailVerification: {
subject: 'Verify your Fluxer email address',
body: `Hello {username},
Please verify your email address for your Fluxer account by clicking the link below:
{verifyUrl}
If you did not create a Fluxer account, you can safely ignore this email.
This link will expire in 24 hours.
- Fluxer Team`,
},
ipAuthorization: {
subject: 'Authorise login from new IP address',
body: `Hello {username},
We detected a login attempt to your Fluxer account from a new IP address:
IP Address: {ipAddress}
Location: {location}
If this was you, please authorise this IP address by clicking the link below:
{authUrl}
If you did not attempt to log in, please change your password immediately.
This authorisation link will expire in 30 minutes.
- Fluxer Team`,
},
accountDisabledSuspicious: {
subject: 'Your Fluxer account has been temporarily disabled',
body: `Hello {username},
Your Fluxer account has been temporarily disabled due to suspicious activity.
{reason, select,
null {}
other {Reason: {reason}
}}To regain access to your account, you must reset your password:
{forgotUrl}
After resetting your password, you will be able to log in again.
If you believe this action was taken in error, please contact our support team.
- Fluxer Safety Team`,
},
accountTempBanned: {
subject: 'Your Fluxer account has been temporarily suspended',
body: `Hello {username},
Your Fluxer account has been temporarily suspended for violating our Terms of Service or Community Guidelines.
Duration: {durationHours, plural,
=1 {1 hour}
other {# hours}
}
Suspended until: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Reason: {reason}}
}
During this time, you will not be able to access your account.
We urge you to review our:
- Terms of Service: {termsUrl}
- Community Guidelines: {guidelinesUrl}
If you believe this enforcement decision was incorrect or unjustified, you may submit an appeal to appeals@fluxer.app from this email address. Please clearly explain why you believe the decision was wrong. We will review your appeal and respond with our determination.
- Fluxer Safety Team`,
},
accountScheduledDeletion: {
subject: 'Your Fluxer account is scheduled for deletion',
body: `Hello {username},
Your Fluxer account has been scheduled for permanent deletion due to violations of our Terms of Service or Community Guidelines.
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Reason: {reason}}
}
This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.
We urge you to review our:
- Terms of Service: {termsUrl}
- Community Guidelines: {guidelinesUrl}
APPEALS PROCESS:
If you believe this enforcement decision was incorrect or unjustified, you have 30 days to submit an appeal to appeals@fluxer.app from this email address.
In your appeal, please:
- Clearly explain why you believe the enforcement decision was incorrect or unjustified
- Provide any relevant evidence or context
A Fluxer Safety Team member will review your appeal and may cancel the pending deletion until a final verdict has been reached.
- Fluxer Safety Team`,
},
selfDeletionScheduled: {
subject: 'Your Fluxer account deletion has been scheduled',
body: `Hello {username},
We're sad to see you go! Your Fluxer account has been scheduled for deletion.
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
IMPORTANT: You can cancel this deletion at any time before {deletionDate, date, full} {deletionDate, time, short} by simply logging back into your account.
BEFORE YOU GO:
Your Privacy Dashboard in User Settings allows you to:
- Delete your messages on the platform
- Extract any valuable data before departing
Please note: Once your account is deleted, there is no way to delete your messages. If you want to delete your messages, please do so through the Privacy Dashboard before your account deletion is finalised.
If you change your mind, just log back in to cancel the deletion.
- Fluxer Team`,
},
inactivityWarning: {
subject: 'Your Fluxer account will be deleted due to inactivity',
body: `Hello {username},
We noticed you haven't logged into your Fluxer account in over 2 years.
Last login: {lastActiveDate, date, full} {lastActiveDate, time, short}
As part of our data retention policy, inactive accounts are automatically scheduled for deletion. Your account will be permanently deleted on:
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
HOW TO KEEP YOUR ACCOUNT:
Simply log in to your account at {loginUrl} before the deletion date to cancel this automatic deletion. No other action is required.
WHAT HAPPENS IF YOU DON'T LOG IN:
- Your account and all associated data will be permanently deleted
- Your messages will be anonymised (attributed to “Deleted User”)
- This action cannot be reversed
WANT TO DELETE YOUR MESSAGES?
If you want to delete your messages before your account is deleted, please log in and use the Privacy Dashboard in User Settings.
We hope to see you back on Fluxer!
- Fluxer Team`,
},
harvestCompleted: {
subject: 'Your Fluxer Data Export is Ready',
body: `Hello {username},
Your data export has been completed and is ready for download!
Export Summary:
- Total messages: {totalMessages, number}
- File size: {fileSizeMB} MB
- Format: ZIP archive with JSON files
Download your data: {downloadUrl}
IMPORTANT: This download link will expire on {expiresAt, date, full} {expiresAt, time, short}
What's included in your export:
- All your messages organised by channel
- Channel metadata
- Your user profile and account information
- Guild memberships and settings
- Authentication sessions and security information
The data is organised in JSON format for easy parsing and analysis.
If you have any questions about your data export, please contact support@fluxer.app
- Fluxer Team`,
},
unbanNotification: {
subject: 'Your Fluxer account suspension has been lifted',
body: `Hello {username},
Good news! Your Fluxer account suspension has been lifted.
Reason: {reason}
You can now log back into your account and continue using Fluxer.
- Fluxer Safety Team`,
},
scheduledDeletionNotification: {
subject: 'Your Fluxer account is scheduled for deletion',
body: `Hello {username},
Your Fluxer account has been scheduled for permanent deletion.
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
Reason: {reason}
This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.
If you believe this enforcement decision was incorrect, you may submit an appeal to appeals@fluxer.app from this email address.
- Fluxer Safety Team`,
},
giftChargebackNotification: {
subject: 'Your Fluxer Premium gift has been revoked',
body: `Hello {username},
We're writing to inform you that the Fluxer Premium gift you redeemed has been revoked due to a payment dispute (chargeback) filed by the original purchaser.
Your premium benefits have been removed from your account. This action was taken because the payment for the gift was disputed and reversed.
If you have questions about this, please contact support@fluxer.app.
- Fluxer Team`,
},
reportResolved: {
subject: 'Your Fluxer report has been reviewed',
body: `Hello {username},
Your report (ID: {reportId}) has been reviewed by our Safety Team.
Response from Safety Team:
{publicComment}
Thank you for helping keep Fluxer safe for everyone. We take all reports seriously and appreciate your contribution to our community.
If you have any questions or concerns about this resolution, please contact safety@fluxer.app.
- Fluxer Safety Team`,
},
dsaReportVerification: {
subject: 'Verify your email for a DSA report',
body: `Hello,
Use the following verification code to submit your Digital Services Act report on Fluxer:
{code}
This code expires at {expiresAt, date, full} {expiresAt, time, short}.
If you did not request this, please ignore this email.
- Fluxer Safety Team`,
},
registrationApproved: {
subject: 'Your Fluxer registration has been approved',
body: `Hello {username},
Great news! Your Fluxer registration has been approved.
You can now log in to the Fluxer app at:
{channelsUrl}
Welcome to the Fluxer community!
- Fluxer Team`,
},
emailChangeRevert: {
subject: 'Your Fluxer email was changed',
body: `Hello {username},
Your Fluxer account email was changed to {newEmail}.
If you made this change, no action is needed. If not, you can revert and secure your account using this link:
{revertUrl}
This will restore your previous email, sign you out everywhere, remove linked phone numbers, disable MFA, and require a new password.
- Fluxer Safety Team`,
},
};

View File

@@ -0,0 +1,346 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const enUS: EmailTranslations = {
passwordReset: {
subject: 'Reset your Fluxer password',
body: `Hello {username},
You requested to reset your Fluxer password. Please follow the link below to set a new password:
{resetUrl}
If you did not request this password reset, you can safely ignore this email.
This link will expire in 1 hour.
- Fluxer Team`,
},
emailVerification: {
subject: 'Verify your Fluxer email address',
body: `Hello {username},
Please verify your email address for your Fluxer account by clicking the link below:
{verifyUrl}
If you did not create a Fluxer account, you can safely ignore this email.
This link will expire in 24 hours.
- Fluxer Team`,
},
emailChangeOriginal: {
subject: 'Confirm your Fluxer email change',
body: `Hello {username},
We received a request to change the email on your Fluxer account.
To confirm this change, enter this code in the app:
{code}
This code expires at {expiresAt, date, full} {expiresAt, time, short}.
If you did not request this, please secure your account immediately.
- Fluxer Team`,
},
emailChangeNew: {
subject: 'Verify your new Fluxer email',
body: `Hello {username},
Enter this code in the app to verify your new Fluxer email:
{code}
This code expires at {expiresAt, date, full} {expiresAt, time, short}.
If you did not request this, you can ignore this email.`,
},
ipAuthorization: {
subject: 'Authorize login from new IP address',
body: `Hello {username},
We detected a login attempt to your Fluxer account from a new IP address:
IP Address: {ipAddress}
Location: {location}
If this was you, please authorize this IP address by clicking the link below:
{authUrl}
If you did not attempt to log in, please change your password immediately.
This authorization link will expire in 30 minutes.
- Fluxer Team`,
},
accountDisabledSuspicious: {
subject: 'Your Fluxer account has been temporarily disabled',
body: `Hello {username},
Your Fluxer account has been temporarily disabled due to suspicious activity.
{reason, select,
null {}
other {Reason: {reason}
}}To regain access to your account, you must reset your password:
{forgotUrl}
After resetting your password, you will be able to log in again.
If you believe this action was taken in error, please contact our support team.
- Fluxer Safety Team`,
},
accountTempBanned: {
subject: 'Your Fluxer account has been temporarily suspended',
body: `Hello {username},
Your Fluxer account has been temporarily suspended for violating our Terms of Service or Community Guidelines.
Duration: {durationHours, plural,
=1 {1 hour}
other {# hours}
}
Suspended until: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Reason: {reason}}
}
During this time, you will not be able to access your account.
We urge you to review our:
- Terms of Service: {termsUrl}
- Community Guidelines: {guidelinesUrl}
If you believe this enforcement decision was incorrect or unjustified, you may submit an appeal to appeals@fluxer.app from this email address. Please clearly explain why you believe the decision was wrong. We will review your appeal and respond with our determination.
- Fluxer Safety Team`,
},
accountScheduledDeletion: {
subject: 'Your Fluxer account is scheduled for deletion',
body: `Hello {username},
Your Fluxer account has been scheduled for permanent deletion due to violations of our Terms of Service or Community Guidelines.
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Reason: {reason}}
}
This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.
We urge you to review our:
- Terms of Service: {termsUrl}
- Community Guidelines: {guidelinesUrl}
APPEALS PROCESS:
If you believe this enforcement decision was incorrect or unjustified, you have 30 days to submit an appeal to appeals@fluxer.app from this email address.
In your appeal, please:
- Clearly explain why you believe the enforcement decision was incorrect or unjustified
- Provide any relevant evidence or context
A Fluxer Safety Team member will review your appeal and may cancel the pending deletion until a final verdict has been reached.
- Fluxer Safety Team`,
},
selfDeletionScheduled: {
subject: 'Your Fluxer account deletion has been scheduled',
body: `Hello {username},
We're sad to see you go! Your Fluxer account has been scheduled for deletion.
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
IMPORTANT: You can cancel this deletion at any time before {deletionDate, date, full} {deletionDate, time, short} by simply logging back into your account.
BEFORE YOU GO:
Your Privacy Dashboard in User Settings allows you to:
- Delete your messages on the platform
- Extract any valuable data before departing
Please note: Once your account is deleted, there is no way to delete your messages. If you want to delete your messages, please do so through the Privacy Dashboard before your account deletion is finalized.
If you change your mind, just log back in to cancel the deletion.
- Fluxer Team`,
},
inactivityWarning: {
subject: 'Your Fluxer account will be deleted due to inactivity',
body: `Hello {username},
We noticed you haven't logged into your Fluxer account in over 2 years.
Last login: {lastActiveDate, date, full} {lastActiveDate, time, short}
As part of our data retention policy, inactive accounts are automatically scheduled for deletion. Your account will be permanently deleted on:
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
HOW TO KEEP YOUR ACCOUNT:
Simply log in to your account at {loginUrl} before the deletion date to cancel this automatic deletion. No other action is required.
WHAT HAPPENS IF YOU DON'T LOG IN:
- Your account and all associated data will be permanently deleted
- Your messages will be anonymized (attributed to "Deleted User")
- This action cannot be reversed
WANT TO DELETE YOUR MESSAGES?
If you want to delete your messages before your account is deleted, please log in and use the Privacy Dashboard in User Settings.
We hope to see you back on Fluxer!
- Fluxer Team`,
},
harvestCompleted: {
subject: 'Your Fluxer Data Export is Ready',
body: `Hello {username},
Your data export has been completed and is ready for download!
Export Summary:
- Total messages: {totalMessages, number}
- File size: {fileSizeMB} MB
- Format: ZIP archive with JSON files
Download your data: {downloadUrl}
IMPORTANT: This download link will expire on {expiresAt, date, full} {expiresAt, time, short}
What's included in your export:
- All your messages organized by channel
- Channel metadata
- Your user profile and account information
- Guild memberships and settings
- Authentication sessions and security information
The data is organized in JSON format for easy parsing and analysis.
If you have any questions about your data export, please contact support@fluxer.app
- Fluxer Team`,
},
unbanNotification: {
subject: 'Your Fluxer account suspension has been lifted',
body: `Hello {username},
Good news! Your Fluxer account suspension has been lifted.
Reason: {reason}
You can now log back into your account and continue using Fluxer.
- Fluxer Safety Team`,
},
scheduledDeletionNotification: {
subject: 'Your Fluxer account is scheduled for deletion',
body: `Hello {username},
Your Fluxer account has been scheduled for permanent deletion.
Scheduled deletion date: {deletionDate, date, full} {deletionDate, time, short}
Reason: {reason}
This is a serious enforcement action. Your account data will be permanently deleted on the scheduled date.
If you believe this enforcement decision was incorrect, you may submit an appeal to appeals@fluxer.app from this email address.
- Fluxer Safety Team`,
},
giftChargebackNotification: {
subject: 'Your Fluxer Premium gift has been revoked',
body: `Hello {username},
We're writing to inform you that the Fluxer Premium gift you redeemed has been revoked due to a payment dispute (chargeback) filed by the original purchaser.
Your premium benefits have been removed from your account. This action was taken because the payment for the gift was disputed and reversed.
If you have questions about this, please contact support@fluxer.app.
- Fluxer Team`,
},
reportResolved: {
subject: 'Your Fluxer report has been reviewed',
body: `Hello {username},
Your report (ID: {reportId}) has been reviewed by our Safety Team.
Response from Safety Team:
{publicComment}
Thank you for helping keep Fluxer safe for everyone. We take all reports seriously and appreciate your contribution to our community.
If you have any questions or concerns about this resolution, please contact safety@fluxer.app.
- Fluxer Safety Team`,
},
dsaReportVerification: {
subject: 'Verify your email for a DSA report',
body: `Hello,
Use the following verification code to submit your Digital Services Act report on Fluxer:
{code}
This code expires at {expiresAt, date, full} {expiresAt, time, short}.
If you did not request this, please ignore this email.
- Fluxer Safety Team`,
},
registrationApproved: {
subject: 'Your Fluxer registration has been approved',
body: `Hello {username},
Great news! Your Fluxer registration has been approved.
You can now log in to the Fluxer app at:
{channelsUrl}
Welcome to the Fluxer community!
- Fluxer Team`,
},
emailChangeRevert: {
subject: 'Your Fluxer email was changed',
body: `Hello {username},
Your Fluxer account email was changed to {newEmail}.
If you made this change, no action is needed. If not, you can revert and secure your account using this link:
{revertUrl}
This will restore your previous email, sign you out everywhere, remove linked phone numbers, disable MFA, and require a new password.
- Fluxer Safety Team`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const es419: EmailTranslations = {
passwordReset: {
subject: 'Restablece tu contraseña de Fluxer',
body: `Hola {username},
Solicitaste restablecer la contraseña de tu cuenta de Fluxer. Por favor sigue el enlace de abajo para establecer una nueva contraseña:
{resetUrl}
Si no solicitaste este restablecimiento, puedes ignorar este correo.
Este enlace expirará en 1 hora.
- Equipo de Fluxer`,
},
emailVerification: {
subject: 'Verifica tu correo electrónico de Fluxer',
body: `Hola {username},
Por favor verifica tu correo electrónico para tu cuenta de Fluxer haciendo clic en el siguiente enlace:
{verifyUrl}
Si no creaste una cuenta en Fluxer, puedes ignorar este correo.
Este enlace expirará en 24 horas.
- Equipo de Fluxer`,
},
ipAuthorization: {
subject: 'Autoriza el acceso desde una nueva dirección IP',
body: `Hola {username},
Detectamos un intento de inicio de sesión en tu cuenta de Fluxer desde una nueva dirección IP:
Dirección IP: {ipAddress}
Ubicación: {location}
Si fuiste tú, autoriza esta dirección IP haciendo clic en el enlace:
{authUrl}
Si no intentaste iniciar sesión, cambia tu contraseña de inmediato.
Este enlace de autorización expirará en 30 minutos.
- Equipo de Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Tu cuenta de Fluxer fue deshabilitada temporalmente',
body: `Hola {username},
Tu cuenta de Fluxer fue deshabilitada temporalmente debido a actividad sospechosa.
{reason, select,
null {}
other {Motivo: {reason}
}}Para recuperar acceso a tu cuenta, debes restablecer tu contraseña:
{forgotUrl}
Después de restablecerla, podrás iniciar sesión nuevamente.
Si crees que esto fue un error, contacta a soporte.
- Equipo de Seguridad de Fluxer`,
},
accountTempBanned: {
subject: 'Tu cuenta de Fluxer fue suspendida temporalmente',
body: `Hola {username},
Tu cuenta de Fluxer fue suspendida temporalmente por violar nuestros Términos de Servicio o Guías de la Comunidad.
Duración: {durationHours, plural,
=1 {1 hora}
other {# horas}
}
Suspendido hasta: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Durante este tiempo no podrás acceder a tu cuenta.
Te recomendamos leer:
- Términos de Servicio: {termsUrl}
- Guías de la Comunidad: {guidelinesUrl}
Si crees que esta decisión es incorrecta o injustificada, puedes enviar una apelación a appeals@fluxer.app desde este correo. Explica claramente por qué consideras que la decisión fue equivocada. Revisaremos tu apelación y te responderemos con un resultado.
- Equipo de Seguridad de Fluxer`,
},
accountScheduledDeletion: {
subject: 'Tu cuenta de Fluxer está programada para eliminación',
body: `Hola {username},
Tu cuenta de Fluxer fue programada para eliminación permanente debido a violaciones de nuestros Términos de Servicio o Guías de la Comunidad.
Fecha programada de eliminación: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Esta es una acción seria. Los datos de tu cuenta serán eliminados permanentemente en esa fecha.
Te recomendamos revisar:
- Términos de Servicio: {termsUrl}
- Guías de la Comunidad: {guidelinesUrl}
PROCESO DE APELACIÓN:
Si crees que esta acción no es correcta, tienes 30 días para enviar una apelación a appeals@fluxer.app desde este correo.
En tu apelación debes:
- Explicar claramente por qué crees que la decisión es incorrecta
- Proporcionar evidencia o contexto relevante
Un miembro del Equipo de Seguridad revisará tu caso y puede pausar la eliminación hasta llegar a una determinación final.
- Equipo de Seguridad de Fluxer`,
},
selfDeletionScheduled: {
subject: 'La eliminación de tu cuenta de Fluxer ha sido programada',
body: `Hola {username},
¡Lamentamos que te vayas! Tu cuenta de Fluxer ha sido programada para eliminación.
Fecha programada de eliminación: {deletionDate, date, full} {deletionDate, time, short}
IMPORTANTE: Puedes cancelar la eliminación en cualquier momento antes de {deletionDate, date, full} {deletionDate, time, short} iniciando sesión nuevamente.
ANTES DE IRTE:
En el Panel de Privacidad en Configuración de Usuario puedes:
- Borrar tus mensajes
- Exportar datos importantes antes de salir
Nota: Cuando tu cuenta sea eliminada, ya no será posible borrar tus mensajes. Si deseas hacerlo, hazlo antes desde el Panel de Privacidad.
Si cambias de opinión, solo inicia sesión para cancelar la eliminación.
- Equipo de Fluxer`,
},
inactivityWarning: {
subject: 'Tu cuenta de Fluxer será eliminada por inactividad',
body: `Hola {username},
Notamos que no has iniciado sesión en tu cuenta de Fluxer por más de 2 años.
Último inicio de sesión: {lastActiveDate, date, full} {lastActiveDate, time, short}
De acuerdo con nuestra política de retención de datos, las cuentas inactivas se programan para eliminación. Tu cuenta será eliminada definitivamente en:
Fecha programada de eliminación: {deletionDate, date, full} {deletionDate, time, short}
CÓMO CONSERVAR TU CUENTA:
Solo debes iniciar sesión antes de la fecha indicada para cancelar esta eliminación automática. No necesitas hacer nada más.
SI NO INICIAS SESIÓN:
- Tu cuenta y todos tus datos serán eliminados permanentemente
- Tus mensajes serán anonimizados (“Usuario eliminado”)
- Esta acción no puede deshacerse
¿QUIERES BORRAR TUS MENSAJES?
Inicia sesión y usa el Panel de Privacidad antes de que tu cuenta sea eliminada.
¡Esperamos verte pronto de vuelta en Fluxer!
- Equipo de Fluxer`,
},
harvestCompleted: {
subject: 'Tu exportación de datos de Fluxer está lista',
body: `Hola {username},
¡Tu exportación de datos está lista para descargar!
Resumen:
- Mensajes totales: {totalMessages, number}
- Tamaño del archivo: {fileSizeMB} MB
- Formato: Archivo ZIP con archivos JSON
Descargar datos: {downloadUrl}
IMPORTANTE: El enlace expirará el {expiresAt, date, full} {expiresAt, time, short}
Incluye:
- Todos tus mensajes organizados por canal
- Metadatos de canales
- Información de cuenta y perfil
- Membresías y configuraciones
- Sesiones de autenticación e información de seguridad
Datos en formato JSON para fácil análisis.
Si tienes dudas, escribe a support@fluxer.app
- Equipo de Fluxer`,
},
unbanNotification: {
subject: 'Tu suspensión de Fluxer ha sido levantada',
body: `Hola {username},
¡Buenas noticias! Tu suspensión en Fluxer ha sido levantada.
Motivo: {reason}
Ahora puedes iniciar sesión nuevamente y continuar usando Fluxer.
- Equipo de Seguridad de Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Tu cuenta de Fluxer está programada para eliminación',
body: `Hola {username},
Tu cuenta de Fluxer fue programada para eliminación definitiva.
Fecha programada de eliminación: {deletionDate, date, full} {deletionDate, time, short}
Motivo: {reason}
Esta es una acción seria. Tus datos serán eliminados permanentemente.
Puedes apelar escribiendo a appeals@fluxer.app desde este correo.
- Equipo de Seguridad de Fluxer`,
},
giftChargebackNotification: {
subject: 'Tu regalo de Fluxer Premium fue revocado',
body: `Hola {username},
Te informamos que el regalo de Fluxer Premium que canjeaste fue revocado debido a un contracargo (chargeback) realizado por el comprador original.
Los beneficios Premium fueron eliminados de tu cuenta. Esto ocurrió porque el pago fue revertido.
Si tienes preguntas, contáctanos en support@fluxer.app.
- Equipo de Fluxer`,
},
reportResolved: {
subject: 'Tu reporte en Fluxer ha sido revisado',
body: `Hola {username},
Tu reporte (ID: {reportId}) fue revisado por nuestro Equipo de Seguridad.
Respuesta del equipo:
{publicComment}
Gracias por ayudar a mantener Fluxer seguro para todos. Valoramos tu colaboración.
Si tienes dudas o inquietudes, escribe a safety@fluxer.app.
- Equipo de Seguridad de Fluxer`,
},
dsaReportVerification: {
subject: 'Verifica tu correo electrónico para un reporte DSA',
body: `Hola,
Usa el siguiente código de verificación para enviar tu reporte de la Ley de Servicios Digitales en Fluxer:
{code}
Este código expira el {expiresAt, date, full} {expiresAt, time, short}.
Si no solicitaste esto, puedes ignorar este correo.
- Equipo de Seguridad de Fluxer`,
},
registrationApproved: {
subject: 'Tu registro en Fluxer fue aprobado',
body: `Hola {username},
¡Buenas noticias! Tu registro en Fluxer fue aprobado.
Ahora puedes ingresar a la aplicación en:
{channelsUrl}
¡Bienvenido a la comunidad de Fluxer!
- Equipo de Fluxer`,
},
emailChangeRevert: {
subject: 'Tu correo de Fluxer fue cambiado',
body: `Hola {username},
El correo electrónico de tu cuenta de Fluxer se cambió a {newEmail}.
Si hiciste este cambio, no necesitas hacer nada. Si no, puedes deshacerlo y proteger tu cuenta con este enlace:
{revertUrl}
Esto restaurará tu correo anterior, cerrará sesión en todos los dispositivos, eliminará los números de teléfono asociados, desactivará el MFA y requerirá una nueva contraseña.
- Equipo de Seguridad de Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const esES: EmailTranslations = {
passwordReset: {
subject: 'Restablece tu contraseña de Fluxer',
body: `Hola {username},
Has solicitado restablecer la contraseña de tu cuenta de Fluxer. Por favor, sigue el enlace de abajo para establecer una nueva contraseña:
{resetUrl}
Si no solicitaste este restablecimiento de contraseña, puedes ignorar este correo de forma segura.
Este enlace expirará en 1 hora.
- Equipo de Fluxer`,
},
emailVerification: {
subject: 'Verifica tu dirección de correo electrónico de Fluxer',
body: `Hola {username},
Por favor verifica la dirección de correo electrónico de tu cuenta de Fluxer haciendo clic en el siguiente enlace:
{verifyUrl}
Si no creaste una cuenta de Fluxer, puedes ignorar este correo de forma segura.
Este enlace expirará en 24 horas.
- Equipo de Fluxer`,
},
ipAuthorization: {
subject: 'Autoriza el inicio de sesión desde una nueva dirección IP',
body: `Hola {username},
Detectamos un intento de inicio de sesión en tu cuenta de Fluxer desde una nueva dirección IP:
Dirección IP: {ipAddress}
Ubicación: {location}
Si fuiste tú, autoriza esta dirección IP haciendo clic en el enlace siguiente:
{authUrl}
Si no intentaste iniciar sesión, por favor cambia tu contraseña inmediatamente.
Este enlace de autorización expirará en 30 minutos.
- Equipo de Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Tu cuenta de Fluxer ha sido desactivada temporalmente',
body: `Hola {username},
Tu cuenta de Fluxer ha sido desactivada temporalmente debido a actividad sospechosa.
{reason, select,
null {}
other {Motivo: {reason}
}}Para recuperar el acceso a tu cuenta, debes restablecer tu contraseña:
{forgotUrl}
Después de restablecer tu contraseña, podrás iniciar sesión nuevamente.
Si crees que esta acción fue un error, por favor contacta a nuestro equipo de soporte.
- Equipo de Seguridad de Fluxer`,
},
accountTempBanned: {
subject: 'Tu cuenta de Fluxer ha sido suspendida temporalmente',
body: `Hola {username},
Tu cuenta de Fluxer ha sido suspendida temporalmente por violar nuestros Términos de Servicio o Normas de la Comunidad.
Duración: {durationHours, plural,
=1 {1 hora}
other {# horas}
}
Suspendido hasta: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Durante este tiempo, no podrás acceder a tu cuenta.
Te recomendamos revisar:
- Términos de Servicio: {termsUrl}
- Normas de la Comunidad: {guidelinesUrl}
Si crees que esta decisión fue incorrecta o injustificada, puedes enviar una apelación a appeals@fluxer.app desde esta dirección de correo electrónico. Explica claramente por qué consideras que la decisión fue errónea. Revisaremos tu apelación y te responderemos con nuestra resolución.
- Equipo de Seguridad de Fluxer`,
},
accountScheduledDeletion: {
subject: 'Tu cuenta de Fluxer está programada para eliminación',
body: `Hola {username},
Tu cuenta de Fluxer ha sido programada para eliminación permanente debido a violaciones de nuestros Términos de Servicio o Normas de la Comunidad.
Fecha de eliminación programada: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Esta es una acción de cumplimiento seria. Los datos de tu cuenta serán eliminados permanentemente en la fecha programada.
Te recomendamos revisar:
- Términos de Servicio: {termsUrl}
- Normas de la Comunidad: {guidelinesUrl}
PROCESO DE APELACIÓN:
Si consideras que esta decisión fue incorrecta o injustificada, tienes 30 días para enviar una apelación a appeals@fluxer.app desde esta dirección de correo electrónico.
En tu apelación:
- Explica claramente por qué consideras que la decisión es incorrecta o injustificada
- Proporciona cualquier evidencia o contexto relevante
Un miembro del Equipo de Seguridad de Fluxer revisará tu apelación y podrá detener la eliminación programada hasta que se tome una decisión final.
- Equipo de Seguridad de Fluxer`,
},
selfDeletionScheduled: {
subject: 'Se ha programado la eliminación de tu cuenta de Fluxer',
body: `Hola {username},
¡Lamentamos que te vayas! La eliminación de tu cuenta de Fluxer ha sido programada.
Fecha de eliminación programada: {deletionDate, date, full} {deletionDate, time, short}
IMPORTANTE: Puedes cancelar esta eliminación en cualquier momento antes de {deletionDate, date, full} {deletionDate, time, short} simplemente iniciando sesión nuevamente en tu cuenta.
ANTES DE IRTE:
Tu Panel de Privacidad en la Configuración de Usuario te permite:
- Eliminar tus mensajes en la plataforma
- Extraer cualquier dato importante antes de irte
Ten en cuenta: Una vez que tu cuenta se elimine, no habrá forma de eliminar tus mensajes. Si deseas borrar tus mensajes, hazlo desde el Panel de Privacidad antes de que se complete la eliminación.
Si cambias de opinión, simplemente inicia sesión nuevamente para cancelar la eliminación.
- Equipo de Fluxer`,
},
inactivityWarning: {
subject: 'Tu cuenta de Fluxer será eliminada por inactividad',
body: `Hola {username},
Notamos que no has iniciado sesión en tu cuenta de Fluxer por más de 2 años.
Último inicio de sesión: {lastActiveDate, date, full} {lastActiveDate, time, short}
Como parte de nuestra política de retención de datos, las cuentas inactivas se programan automáticamente para eliminación. Tu cuenta será eliminada permanentemente el:
Fecha de eliminación programada: {deletionDate, date, full} {deletionDate, time, short}
CÓMO CONSERVAR TU CUENTA:
Simplemente inicia sesión en tu cuenta en {loginUrl} antes de la fecha de eliminación para cancelar esta eliminación automática. No se requiere ninguna otra acción.
¿QUÉ SUCEDE SI NO INICIAS SESIÓN?
- Tu cuenta y todos los datos asociados serán eliminados permanentemente
- Tus mensajes serán anonimizados (atribuidos a “Usuario Eliminado”)
- Esta acción no se puede revertir
¿QUIERES ELIMINAR TUS MENSAJES?
Si deseas eliminar tus mensajes antes de que tu cuenta sea eliminada, inicia sesión y utiliza el Panel de Privacidad en Configuración de Usuario.
¡Esperamos verte de vuelta en Fluxer!
- Equipo de Fluxer`,
},
harvestCompleted: {
subject: 'Tu exportación de datos de Fluxer está lista',
body: `Hola {username},
¡Tu exportación de datos ha sido completada y está lista para descargarse!
Resumen de exportación:
- Total de mensajes: {totalMessages, number}
- Tamaño del archivo: {fileSizeMB} MB
- Formato: Archivo ZIP con archivos JSON
Descarga tus datos: {downloadUrl}
IMPORTANTE: Este enlace de descarga expirará el {expiresAt, date, full} {expiresAt, time, short}
Lo que incluye tu exportación:
- Todos tus mensajes organizados por canal
- Metadatos de los canales
- Tu perfil de usuario e información de la cuenta
- Membresías y ajustes de guilds
- Sesiones de autenticación e información de seguridad
Los datos están organizados en formato JSON para facilitar su análisis.
Si tienes alguna pregunta sobre tu exportación de datos, contacta a support@fluxer.app
- Equipo de Fluxer`,
},
unbanNotification: {
subject: 'La suspensión de tu cuenta de Fluxer ha sido levantada',
body: `Hola {username},
¡Buenas noticias! La suspensión de tu cuenta de Fluxer ha sido levantada.
Motivo: {reason}
Ya puedes iniciar sesión nuevamente y continuar usando Fluxer.
- Equipo de Seguridad de Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Tu cuenta de Fluxer está programada para eliminación',
body: `Hola {username},
Tu cuenta de Fluxer ha sido programada para eliminación permanente.
Fecha de eliminación programada: {deletionDate, date, full} {deletionDate, time, short}
Motivo: {reason}
Esta es una acción seria de cumplimiento. Los datos de tu cuenta serán eliminados permanentemente en la fecha programada.
Si crees que esta decisión fue incorrecta, puedes enviar una apelación a appeals@fluxer.app desde esta dirección de correo electrónico.
- Equipo de Seguridad de Fluxer`,
},
giftChargebackNotification: {
subject: 'Tu regalo de Fluxer Premium ha sido revocado',
body: `Hola {username},
Te informamos que el regalo de Fluxer Premium que canjeaste ha sido revocado debido a una disputa de pago (chargeback) presentada por el comprador original.
Tus beneficios premium han sido eliminados de tu cuenta. Esta acción se tomó porque el pago del regalo fue disputado y revertido.
Si tienes preguntas sobre esto, contacta a support@fluxer.app.
- Equipo de Fluxer`,
},
reportResolved: {
subject: 'Tu reporte en Fluxer ha sido revisado',
body: `Hola {username},
Tu reporte (ID: {reportId}) ha sido revisado por nuestro Equipo de Seguridad.
Respuesta del Equipo de Seguridad:
{publicComment}
Gracias por ayudar a mantener Fluxer seguro para todos. Tomamos todos los reportes en serio y apreciamos tu contribución a nuestra comunidad.
Si tienes preguntas o inquietudes sobre esta resolución, contacta a safety@fluxer.app.
- Equipo de Seguridad de Fluxer`,
},
dsaReportVerification: {
subject: 'Verifica tu correo para un reporte DSA',
body: `Hola,
Usa el siguiente código de verificación para enviar tu reporte de la Ley de Servicios Digitales en Fluxer:
{code}
Este código expira el {expiresAt, date, full} {expiresAt, time, short}.
Si no solicitaste esto, por favor ignora este correo.
- Equipo de Seguridad de Fluxer`,
},
registrationApproved: {
subject: 'Tu registro en Fluxer ha sido aprobado',
body: `Hola {username},
¡Buenas noticias! Tu registro en Fluxer ha sido aprobado.
Ahora puedes iniciar sesión en la aplicación de Fluxer en:
{channelsUrl}
¡Bienvenido a la comunidad de Fluxer!
- Equipo de Fluxer`,
},
emailChangeRevert: {
subject: 'Tu correo de Fluxer ha cambiado',
body: `Hola {username},
El correo electrónico de tu cuenta de Fluxer se cambió a {newEmail}.
Si realizaste este cambio, no necesitas hacer nada. Si no, puedes revertirlo y proteger tu cuenta con este enlace:
{revertUrl}
Esto restaurará tu correo anterior, cerrará tu sesión en todos los dispositivos, eliminará los números de teléfono vinculados, desactivará el MFA y requerirá una nueva contraseña.
- Equipo de Seguridad de Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const fi: EmailTranslations = {
passwordReset: {
subject: 'Nollaa Fluxer-salasanasi',
body: `Hei {username},
Olet pyytänyt Fluxer-tilisi salasanan palauttamista. Seuraa alla olevaa linkkiä asettaaksesi uuden salasanan:
{resetUrl}
Jos et pyytänyt salasanan palautusta, voit turvallisesti jättää tämän sähköpostin huomiotta.
Tämä linkki vanhenee 1 tunnin kuluttua.
- Fluxer-tiimi`,
},
emailVerification: {
subject: 'Vahvista Fluxer-sähköpostiosoitteesi',
body: `Hei {username},
Vahvista Fluxer-tilisi sähköpostiosoite napsauttamalla alla olevaa linkkiä:
{verifyUrl}
Jos et luonut Fluxer-tiliä, voit turvallisesti jättää tämän viestin huomiotta.
Tämä linkki vanhenee 24 tunnin kuluttua.
- Fluxer-tiimi`,
},
ipAuthorization: {
subject: 'Hyväksy kirjautuminen uudesta IP-osoitteesta',
body: `Hei {username},
Havaitsimme kirjautumisyrityksen Fluxer-tilillesi uudesta IP-osoitteesta:
IP-osoite: {ipAddress}
Sijainti: {location}
Jos tämä olit sinä, hyväksy tämä IP-osoite napsauttamalla alla olevaa linkkiä:
{authUrl}
Jos et yrittänyt kirjautua sisään, vaihda salasanasi välittömästi.
Tämä valtuutuslinkki vanhenee 30 minuutissa.
- Fluxer-tiimi`,
},
accountDisabledSuspicious: {
subject: 'Fluxer-tilisi on tilapäisesti poistettu käytöstä',
body: `Hei {username},
Fluxer-tilisi on poistettu tilapäisesti käytöstä epäilyttävän toiminnan vuoksi.
{reason, select,
null {}
other {Syy: {reason}
}}Saadaksesi tilisi takaisin käyttöösi sinun täytyy palauttaa salasanasi:
{forgotUrl}
Kun olet palauttanut salasanan, voit kirjautua sisään uudelleen.
Jos epäilet tämän tapahtuneen virheellisesti, ota yhteyttä tukitiimiimme.
- Fluxerin turvallisuustiimi`,
},
accountTempBanned: {
subject: 'Fluxer-tilisi on tilapäisesti estetty',
body: `Hei {username},
Fluxer-tilisi on tilapäisesti estetty, koska olet rikkonut käyttöehtojamme tai yhteisöohjeitamme.
Kesto: {durationHours, plural,
=1 {1 tunti}
other {# tuntia}
}
Estetty asti: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Syy: {reason}}
}
Tänä aikana et voi käyttää tiliäsi.
Suosittelemme tutustumaan seuraaviin:
- Käyttöehdot: {termsUrl}
- Yhteisöohjeet: {guidelinesUrl}
Jos uskot, että tämä päätös on virheellinen tai perusteeton, voit lähettää valituksen osoitteeseen appeals@fluxer.app tästä sähköpostiosoitteesta. Kerro selkeästi, miksi päätös mielestäsi oli väärä. Arvioimme valituksesi ja ilmoitamme ratkaisusta.
- Fluxerin turvallisuustiimi`,
},
accountScheduledDeletion: {
subject: 'Fluxer-tilisi on aikataulutettu poistettavaksi',
body: `Hei {username},
Fluxer-tilisi on aikataulutettu pysyvästi poistettavaksi, koska olet rikkonut käyttöehtojamme tai yhteisöohjeitamme.
Poistopäivämäärä: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Syy: {reason}}
}
Tämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi annetun aikataulun mukaisesti.
Suosittelemme tutustumaan:
- Käyttöehdot: {termsUrl}
- Yhteisöohjeet: {guidelinesUrl}
VALITUSPROSESSI:
Jos uskot, että tämä päätös on virheellinen, sinulla on 30 päivää aikaa lähettää valitus osoitteeseen appeals@fluxer.app tästä sähköpostiosoitteesta.
Valituksessa:
- Selitä selkeästi, miksi päätös on mielestäsi väärä
- Toimita tarvittavat lisätiedot tai todisteet
Turvallisuustiimimme arvioi valituksesi ja voi keskeyttää poiston, kunnes lopullinen päätös tehdään.
- Fluxerin turvallisuustiimi`,
},
selfDeletionScheduled: {
subject: 'Fluxer-tilisi poisto on aikataulutettu',
body: `Hei {username},
Ikävä kuulla, että olet lähdössä! Fluxer-tilisi on aikataulutettu poistettavaksi.
Poistopäivämäärä: {deletionDate, date, full} {deletionDate, time, short}
TÄRKEÄÄ: Voit peruuttaa poiston milloin tahansa ennen {deletionDate, date, full} {deletionDate, time, short} kirjautumalla uudelleen sisään.
ENNEN KUIN LÄHDET:
Tietosuoja-asetuksesi käyttäjäasetuksissa sallivat sinun:
- Poistaa viestisi alustalta
- Ladata arvokasta dataa ennen lähtöä
Huomio: Kun tilisi on poistettu, et voi enää poistaa viestejäsi. Jos haluat poistaa ne, tee se ennen tilin poistoa.
Jos muutat mielesi, kirjaudu uudelleen sisään peruuttaaksesi poiston.
- Fluxer-tiimi`,
},
inactivityWarning: {
subject: 'Fluxer-tilisi poistetaan toimettomuuden vuoksi',
body: `Hei {username},
Emme ole havainneet kirjautumisia Fluxer-tilillesi yli kahteen vuoteen.
Viimeisin kirjautuminen: {lastActiveDate, date, full} {lastActiveDate, time, short}
Tietojen säilytyskäytännön mukaisesti toimettomat tilit aikataulutetaan poistettaviksi automaattisesti. Tilisi poistetaan pysyvästi:
Poistopäivämäärä: {deletionDate, date, full} {deletionDate, time, short}
NÄIN SÄILYTÄT TILISI:
Kirjaudu sisään osoitteessa {loginUrl} ennen poistopäivää. Tämä riittää muita toimenpiteitä ei tarvita.
JOS ET KIRJAUDU SISÄÄN:
- Tilisi ja kaikki siihen liittyvät tiedot poistetaan pysyvästi
- Viestisi anonymisoidaan (“Poistettu käyttäjä”)
- Toimintoa ei voi perua
HALUATKO POISTAA VIESTEJÄSI?
Jos haluat poistaa viestit ennen tilisi poistoa, kirjaudu sisään ja käytä Tietosuoja-paneelia.
Toivottavasti näemme sinut vielä Fluxerissa!
- Fluxer-tiimi`,
},
harvestCompleted: {
subject: 'Fluxer-datan vienti on valmis',
body: `Hei {username},
Datan vientisi on valmis ja ladattavissa!
Yhteenveto:
- Viestejä yhteensä: {totalMessages, number}
- Tiedoston koko: {fileSizeMB} Mt
- Muoto: ZIP-arkisto, joka sisältää JSON-tiedostoja
Lataa datasi: {downloadUrl}
TÄRKEÄÄ: Tämä latauslinkki vanhenee {expiresAt, date, full} {expiresAt, time, short}
Vienti sisältää:
- Kaikki viestisi kanavittain järjestettynä
- Kanavien metadata
- Käyttäjäprofiilisi ja tilitietosi
- Guild-jäsenyydet ja asetukset
- Autentikaatiosessiot ja turvallisuustiedot
Data on JSON-muodossa helppoa käsittelyä varten.
Kysyttävää? Ota yhteyttä: support@fluxer.app
- Fluxer-tiimi`,
},
unbanNotification: {
subject: 'Fluxer-tunnuksesi porttikielto on poistettu',
body: `Hei {username},
Hyviä uutisia! Fluxer-tilisi porttikielto on poistettu.
Syy: {reason}
Voit nyt kirjautua takaisin sisään ja jatkaa Fluxerin käyttöä.
- Fluxerin turvallisuustiimi`,
},
scheduledDeletionNotification: {
subject: 'Fluxer-tilisi on aikataulutettu poistettavaksi',
body: `Hei {username},
Fluxer-tilisi on aikataulutettu pysyvästi poistettavaksi.
Poistopäivämäärä: {deletionDate, date, full} {deletionDate, time, short}
Syy: {reason}
Tämä on vakava toimenpide. Tilisi tiedot poistetaan pysyvästi annetun aikataulun mukaan.
Jos uskot päätöksen olleen virheellinen, voit lähettää valituksen osoitteeseen appeals@fluxer.app tästä sähköpostista.
- Fluxerin turvallisuustiimi`,
},
giftChargebackNotification: {
subject: 'Fluxer Premium -lahjasi on peruttu',
body: `Hei {username},
Haluamme ilmoittaa, että Fluxer Premium -lahja, jonka lunastit, on peruttu maksukiistan (chargeback) vuoksi, jonka alkuperäinen ostaja teki.
Premium-edut on poistettu tililtäsi. Tämä tapahtui, koska lahjan maksu peruutettiin.
Jos sinulla on kysymyksiä, ota yhteyttä: support@fluxer.app.
- Fluxer-tiimi`,
},
reportResolved: {
subject: 'Fluxer-ilmoituksesi on käsitelty',
body: `Hei {username},
Ilmoituksesi (ID: {reportId}) on käsitelty turvallisuustiimimme toimesta.
Turvallisuustiimin vastaus:
{publicComment}
Kiitos, että autat pitämään Fluxerin turvallisena kaikille. Arvostamme panostasi yhteisöömme.
Jos sinulla on kysymyksiä tai huolia tästä päätöksestä, ota yhteyttä: safety@fluxer.app.
- Fluxerin turvallisuustiimi`,
},
dsaReportVerification: {
subject: 'Vahvista sähköpostisi DSA-ilmoitusta varten',
body: `Hei,
Käytä seuraavaa vahvistuskoodia lähettääksesi digitaalisten palveluiden lain (DSA) ilmoituksen Fluxerissa:
{code}
Tämä koodi vanhenee {expiresAt, date, full} {expiresAt, time, short}.
Jos et pyytänyt tätä, voit jättää tämän viestin huomiotta.
- Fluxerin turvallisuustiimi`,
},
registrationApproved: {
subject: 'Fluxer-rekisteröintisi on hyväksytty',
body: `Hei {username},
Hienoja uutisia! Fluxer-rekisteröintisi on hyväksytty.
Pääset kirjautumaan Fluxer-sovellukseen osoitteessa:
{channelsUrl}
Tervetuloa Fluxer-yhteisöön!
- Fluxer-tiimi`,
},
emailChangeRevert: {
subject: 'Fluxer-sähköpostisi on muutettu',
body: `Hei {username},
Fluxer-tilisi sähköpostiosoite on muutettu osoitteeseen {newEmail}.
Jos teit muutoksen itse, mitään ei tarvitse tehdä. Ellet tehnyt, voit perua muutoksen ja suojata tilisi tämän linkin kautta:
{revertUrl}
Tämä palauttaa aiemman sähköpostin, kirjaa sinut ulos kaikkialta, poistaa liitetyt puhelinnumerot, poistaa MFA:n käytöstä ja edellyttää uutta salasanaa.
- Fluxer-turvatiimi`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const fr: EmailTranslations = {
passwordReset: {
subject: 'Réinitialisez votre mot de passe Fluxer',
body: `Bonjour {username},
Vous avez demandé à réinitialiser le mot de passe de votre compte Fluxer. Veuillez suivre le lien ci-dessous pour définir un nouveau mot de passe :
{resetUrl}
Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail en toute sécurité.
Ce lien expirera dans 1 heure.
- L'équipe Fluxer`,
},
emailVerification: {
subject: 'Vérifiez votre adresse e-mail Fluxer',
body: `Bonjour {username},
Veuillez vérifier l'adresse e-mail associée à votre compte Fluxer en cliquant sur le lien ci-dessous :
{verifyUrl}
Si vous n'avez pas créé de compte Fluxer, vous pouvez ignorer cet e-mail en toute sécurité.
Ce lien expirera dans 24 heures.
- L'équipe Fluxer`,
},
ipAuthorization: {
subject: 'Autorisez une connexion depuis une nouvelle adresse IP',
body: `Bonjour {username},
Nous avons détecté une tentative de connexion à votre compte Fluxer depuis une nouvelle adresse IP :
Adresse IP : {ipAddress}
Localisation : {location}
Si c'était bien vous, veuillez autoriser cette adresse IP en cliquant sur le lien ci-dessous :
{authUrl}
Si vous n'avez pas tenté de vous connecter, veuillez modifier votre mot de passe immédiatement.
Ce lien d'autorisation expirera dans 30 minutes.
- L'équipe Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Votre compte Fluxer a été temporairement désactivé',
body: `Bonjour {username},
Votre compte Fluxer a été temporairement désactivé en raison d'une activité suspecte.
{reason, select,
null {}
other {Raison : {reason}
}}Pour retrouver l'accès à votre compte, vous devez réinitialiser votre mot de passe :
{forgotUrl}
Après avoir réinitialisé votre mot de passe, vous pourrez vous reconnecter.
Si vous pensez qu'il s'agit d'une erreur, veuillez contacter notre équipe d'assistance.
- L'équipe Sécurité Fluxer`,
},
accountTempBanned: {
subject: 'Votre compte Fluxer a été temporairement suspendu',
body: `Bonjour {username},
Votre compte Fluxer a été temporairement suspendu pour non-respect de nos Conditions d'utilisation ou de nos Règles communautaires.
Durée : {durationHours, plural,
=1 {1 heure}
other {# heures}
}
Suspendu jusqu'au : {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Raison : {reason}}
}
Pendant cette période, vous ne pourrez pas accéder à votre compte.
Nous vous invitons à consulter :
- Conditions d'utilisation : {termsUrl}
- Règles communautaires : {guidelinesUrl}
Si vous estimez que cette décision est incorrecte ou injustifiée, vous pouvez envoyer une demande d'appel à appeals@fluxer.app depuis cette adresse e-mail. Veuillez expliquer clairement pourquoi vous pensez que la décision est erronée. Nous étudierons votre appel et reviendrons vers vous avec notre décision.
- L'équipe Sécurité Fluxer`,
},
accountScheduledDeletion: {
subject: 'Votre compte Fluxer est programmé pour suppression',
body: `Bonjour {username},
Votre compte Fluxer a été programmé pour suppression définitive en raison d'infractions à nos Conditions d'utilisation ou Règles communautaires.
Date de suppression prévue : {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Raison : {reason}}
}
Ceci est une mesure disciplinaire sérieuse. Vos données seront supprimées définitivement à la date prévue.
Nous vous invitons à consulter :
- Conditions d'utilisation : {termsUrl}
- Règles communautaires : {guidelinesUrl}
PROCÉDURE D'APPEL :
Si vous estimez que cette décision est incorrecte ou injustifiée, vous disposez de 30 jours pour envoyer un appel à appeals@fluxer.app depuis cette adresse.
Dans votre appel :
- Expliquez clairement pourquoi vous estimez que la décision est incorrecte
- Fournissez tout élément pertinent ou tout contexte utile
Un membre de l'équipe Sécurité Fluxer examinera votre demande et pourra suspendre la suppression jusqu'à ce qu'une décision finale soit prise.
- L'équipe Sécurité Fluxer`,
},
selfDeletionScheduled: {
subject: 'La suppression de votre compte Fluxer a été planifiée',
body: `Bonjour {username},
Nous sommes désolés de vous voir partir ! La suppression de votre compte Fluxer a été programmée.
Date prévue de suppression : {deletionDate, date, full} {deletionDate, time, short}
IMPORTANT : Vous pouvez annuler cette suppression à tout moment avant le {deletionDate, date, full} {deletionDate, time, short} en vous reconnectant simplement à votre compte.
AVANT DE PARTIR :
Votre tableau de bord de confidentialité dans les paramètres utilisateur vous permet de :
- Supprimer vos messages sur la plateforme
- Exporter vos données importantes avant de partir
Veuillez noter : une fois votre compte supprimé, il ne sera plus possible de supprimer vos messages. Si vous souhaitez les effacer, faites-le via le tableau de bord de confidentialité avant la suppression définitive.
Si vous changez d'avis, reconnectez-vous simplement pour annuler la suppression.
- L'équipe Fluxer`,
},
inactivityWarning: {
subject: 'Votre compte Fluxer sera supprimé pour inactivité',
body: `Bonjour {username},
Nous avons remarqué que vous ne vous êtes pas connecté à votre compte Fluxer depuis plus de 2 ans.
Dernière connexion : {lastActiveDate, date, full} {lastActiveDate, time, short}
Dans le cadre de notre politique de conservation des données, les comptes inactifs sont automatiquement programmés pour suppression. Votre compte sera supprimé définitivement le :
Date prévue de suppression : {deletionDate, date, full} {deletionDate, time, short}
COMMENT GARDER VOTRE COMPTE :
Il vous suffit de vous connecter à votre compte à {loginUrl} avant la date de suppression pour annuler cette suppression automatique. Aucune autre action n'est nécessaire.
SI VOUS NE VOUS CONNECTEZ PAS :
- Votre compte et toutes les données associées seront supprimés définitivement
- Vos messages seront rendus anonymes (attribués à « Utilisateur supprimé »)
- Cette action est irréversible
VOUS SOUHAITEZ SUPPRIMER VOS MESSAGES ?
Si vous souhaitez effacer vos messages avant la suppression du compte, veuillez vous connecter et utiliser le tableau de bord de confidentialité.
Nous espérons vous revoir bientôt sur Fluxer !
- L'équipe Fluxer`,
},
harvestCompleted: {
subject: 'Votre exportation de données Fluxer est prête',
body: `Bonjour {username},
Votre exportation de données est terminée et prête à être téléchargée !
Résumé de l'export :
- Nombre total de messages : {totalMessages, number}
- Taille du fichier : {fileSizeMB} Mo
- Format : Archive ZIP contenant des fichiers JSON
Téléchargez vos données : {downloadUrl}
IMPORTANT : Ce lien expirera le {expiresAt, date, full} {expiresAt, time, short}
Contenu de l'export :
- Tous vos messages organisés par canal
- Métadonnées des canaux
- Votre profil utilisateur et informations de compte
- Vos appartenances et paramètres de serveurs (guildes)
- Vos sessions d'authentification et informations de sécurité
Les données sont fournies au format JSON pour faciliter l'analyse.
Pour toute question, veuillez contacter support@fluxer.app
- L'équipe Fluxer`,
},
unbanNotification: {
subject: 'La suspension de votre compte Fluxer a été levée',
body: `Bonjour {username},
Bonne nouvelle ! La suspension de votre compte Fluxer a été levée.
Raison : {reason}
Vous pouvez désormais vous reconnecter et continuer à utiliser Fluxer.
- L'équipe Sécurité Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Votre compte Fluxer est programmé pour suppression',
body: `Bonjour {username},
Votre compte Fluxer a été programmé pour suppression définitive.
Date prévue de suppression : {deletionDate, date, full} {deletionDate, time, short}
Raison : {reason}
Il s'agit d'une mesure disciplinaire sérieuse. Vos données de compte seront supprimées définitivement à la date indiquée.
Si vous pensez que cette décision est incorrecte, vous pouvez envoyer un appel à appeals@fluxer.app depuis cette adresse e-mail.
- L'équipe Sécurité Fluxer`,
},
giftChargebackNotification: {
subject: 'Votre cadeau Fluxer Premium a été révoqué',
body: `Bonjour {username},
Nous vous informons que le cadeau Fluxer Premium que vous avez utilisé a été révoqué à la suite d'un litige de paiement (chargeback) déposé par l'acheteur initial.
Vos avantages Premium ont été retirés de votre compte. Cette action a été effectuée car le paiement a été contesté et annulé.
Si vous avez des questions, veuillez contacter support@fluxer.app.
- L'équipe Fluxer`,
},
reportResolved: {
subject: 'Votre signalement Fluxer a été examiné',
body: `Bonjour {username},
Votre signalement (ID : {reportId}) a été examiné par notre équipe Sécurité.
Réponse de l'équipe :
{publicComment}
Merci d'aider à faire de Fluxer un espace sûr pour tous. Nous prenons tous les signalements au sérieux et apprécions votre contribution.
Si vous avez des questions ou des préoccupations concernant cette décision, veuillez contacter safety@fluxer.app.
- L'équipe Sécurité Fluxer`,
},
dsaReportVerification: {
subject: 'Vérifiez votre e-mail pour un signalement DSA',
body: `Bonjour,
Utilisez le code de vérification suivant pour soumettre votre signalement conformément à la loi sur les services numériques (Digital Services Act) sur Fluxer :
{code}
Ce code expire le {expiresAt, date, full} {expiresAt, time, short}.
Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.
- L'équipe Sécurité Fluxer`,
},
registrationApproved: {
subject: 'Votre inscription Fluxer a été approuvée',
body: `Bonjour {username},
Bonne nouvelle ! Votre inscription à Fluxer a été approuvée.
Vous pouvez maintenant vous connecter à l'application Fluxer à l'adresse :
{channelsUrl}
Bienvenue dans la communauté Fluxer !
- L'équipe Fluxer`,
},
emailChangeRevert: {
subject: 'Votre e-mail Fluxer a été modifié',
body: `Bonjour {username},
L'adresse e-mail de votre compte Fluxer a été modifiée en {newEmail}.
Si vous êtes à l'origine de ce changement, vous n'avez rien à faire. Sinon, vous pouvez l'annuler et sécuriser votre compte avec ce lien :
{revertUrl}
Cela restaurera votre ancienne adresse e-mail, vous déconnectera partout, supprimera les numéros de téléphone associés, désactivera la MFA et exigera un nouveau mot de passe.
- Équipe Sécurité Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const he: EmailTranslations = {
passwordReset: {
subject: 'איפוס סיסמה לחשבון Fluxer שלך',
body: `שלום {username},
ביקשת לאפס את הסיסמה לחשבון ה-Fluxer שלך. אנא עקוב אחר הקישור שלמטה כדי להגדיר סיסמה חדשה:
{resetUrl}
אם לא ביקשת איפוס סיסמה, ניתן להתעלם מהודעה זו בבטחה.
תוקף הקישור יפוג בעוד שעה.
- צוות Fluxer`,
},
emailVerification: {
subject: 'אימות כתובת האימייל שלך ב-Fluxer',
body: `שלום {username},
אנא אמת את כתובת האימייל של חשבון ה-Fluxer שלך על ידי לחיצה על הקישור:
{verifyUrl}
אם לא יצרת חשבון Fluxer, ניתן להתעלם מהודעה זו בבטחה.
תוקף הקישור יפוג בעוד 24 שעות.
- צוות Fluxer`,
},
ipAuthorization: {
subject: 'אישור התחברות מכתובת IP חדשה',
body: `שלום {username},
זיהינו ניסיון התחברות לחשבון ה-Fluxer שלך מכתובת IP חדשה:
כתובת IP: {ipAddress}
מיקום: {location}
אם זה היית אתה, אנא אשר את כתובת ה-IP באמצעות הקישור:
{authUrl}
אם לא ניסית להתחבר, יש לשנות את הסיסמה מיד.
קישור האישור יפוג בעוד 30 דקות.
- צוות Fluxer`,
},
accountDisabledSuspicious: {
subject: 'החשבון שלך ב-Fluxer הושבת זמנית',
body: `שלום {username},
החשבון שלך ב-Fluxer הושבת זמנית בעקבות פעילות חשודה.
{reason, select,
null {}
other {סיבה: {reason}
}}על מנת לשחזר את הגישה לחשבונך, עליך לאפס את הסיסמה:
{forgotUrl}
לאחר איפוס הסיסמה תוכל להתחבר מחדש.
אם אתה מאמין שהפעולה בוצעה בטעות, אנא פנה לצוות התמיכה שלנו.
- צוות האבטחה של Fluxer`,
},
accountTempBanned: {
subject: 'החשבון שלך ב-Fluxer הושעה זמנית',
body: `שלום {username},
החשבון שלך ב-Fluxer הושעה זמנית עקב הפרת תנאי השירות או כללי הקהילה.
משך השעיה: {durationHours, plural,
=1 {שעה אחת}
other {# שעות}
}
מושעה עד: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
סיבה: {reason}}
}
במהלך תקופה זו לא תהיה לך גישה לחשבון.
אנו ממליצים לעיין ב:
- תנאי השירות: {termsUrl}
- כללי הקהילה: {guidelinesUrl}
אם אתה מאמין שההחלטה שגויה או בלתי מוצדקת, תוכל להגיש ערעור ל-appeals@fluxer.app מהאימייל הזה. אנא פרט מדוע אתה מאמין שההחלטה אינה נכונה. אנו נבחן את הערעור ונשיב עם החלטתנו.
- צוות האבטחה של Fluxer`,
},
accountScheduledDeletion: {
subject: 'החשבון שלך ב-Fluxer מתוכנן למחיקה',
body: `שלום {username},
החשבון שלך ב-Fluxer מתוכנן למחיקה קבועה עקב הפרת תנאי השירות או כללי הקהילה.
תאריך מחיקה מתוכנן: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
סיבה: {reason}}
}
מדובר בפעולת אכיפה משמעותית. נתוני החשבון יימחקו לצמיתות בתאריך המתוכנן.
אנו ממליצים לעיין ב:
- תנאי השירות: {termsUrl}
- כללי הקהילה: {guidelinesUrl}
תהליך הערעור:
אם אתה מאמין שההחלטה שגויה או בלתי מוצדקת, עומדים לרשותך 30 ימים לשלוח ערעור ל-appeals@fluxer.app מהאימייל הזה.
בערעור שלך:
- פרט בבירור מדוע ההחלטה שגויה או בלתי מוצדקת
- צרף הוכחות או מידע רלוונטי
צוות האבטחה של Fluxer יבחן את הערעור וייתכן שיעכב את המחיקה עד לקבלת החלטה סופית.
- צוות האבטחה של Fluxer`,
},
selfDeletionScheduled: {
subject: 'מחיקת חשבון ה-Fluxer שלך נקבעה',
body: `שלום {username},
עצוב לנו לראות אותך עוזב! מחיקת חשבון ה-Fluxer שלך נקבעה.
תאריך מחיקה מתוכנן: {deletionDate, date, full} {deletionDate, time, short}
חשוב: ניתן לבטל את המחיקה בכל עת לפני {deletionDate, date, full} {deletionDate, time, short} על ידי התחברות מחדש לחשבון.
לפני שאתה עוזב:
לוח הפרטיות בהגדרות המשתמש מאפשר לך:
- למחוק את הודעותיך בפלטפורמה
- לייצא נתונים חשובים לפני עזיבה
לתשומת ליבך: לאחר מחיקת החשבון, לא ניתן יהיה למחוק את ההודעות. אם ברצונך למחוק הודעות, עשה זאת מראש מלוח הפרטיות.
אם שינית את דעתך פשוט התחבר מחדש כדי לבטל את המחיקה.
- צוות Fluxer`,
},
inactivityWarning: {
subject: 'החשבון שלך ב-Fluxer יימחק עקב חוסר פעילות',
body: `שלום {username},
שמנו לב שלא התחברת לחשבון ה-Fluxer שלך במשך יותר משנתיים.
התחברות אחרונה: {lastActiveDate, date, full} {lastActiveDate, time, short}
בהתאם למדיניות שמירת הנתונים שלנו, חשבונות לא פעילים מתוזמנים למחיקה אוטומטית. חשבונך יימחק לצמיתות ב:
תאריך מחיקה מתוכנן: {deletionDate, date, full} {deletionDate, time, short}
כיצד לשמור על החשבון:
פשוט התחבר ל-{loginUrl} לפני תאריך המחיקה כדי לבטל את המחיקה האוטומטית. אין צורך בפעולה נוספת.
אם לא תתחבר:
- החשבון וכל נתוניו יימחקו לצמיתות
- ההודעות שלך יאונונימיות (ישויכו ל"משתמש שנמחק")
- הפעולה אינה הפיכה
רוצה למחוק את הודעותיך?
אם אתה מעוניין למחוק הודעות לפני מחיקת החשבון, פשוט התחבר והשתמש בלוח הפרטיות.
מקווים לראותך שוב ב-Fluxer!
- צוות Fluxer`,
},
harvestCompleted: {
subject: 'ייצוא הנתונים שלך מ-Fluxer מוכן',
body: `שלום {username},
ייצוא הנתונים שלך הושלם והוא מוכן להורדה!
סיכום הייצוא:
- סך כל ההודעות: {totalMessages, number}
- גודל הקובץ: {fileSizeMB} מ״ב
- פורמט: ארכיון ZIP עם קבצי JSON
הורד את הנתונים שלך: {downloadUrl}
חשוב: קישור זה יפוג בתאריך {expiresAt, date, full} {expiresAt, time, short}
הייצוא כולל:
- את כל הודעותיך, מאורגנות לפי ערוץ
- מטא־נתונים של הערוצים
- פרופיל משתמש ומידע על החשבון
- חברות בגילדות והגדרות
- סשנים של אימות ומידע אבטחתי
הנתונים מסודרים בפורמט JSON למען ניתוח קל.
לשאלות נוספות: support@fluxer.app
- צוות Fluxer`,
},
unbanNotification: {
subject: 'השעיית חשבון ה-Fluxer שלך הוסרה',
body: `שלום {username},
חדשות טובות! השעיית חשבון ה-Fluxer שלך הוסרה.
סיבה: {reason}
כעת תוכל להתחבר שוב ולהמשיך להשתמש ב-Fluxer.
- צוות האבטחה של Fluxer`,
},
scheduledDeletionNotification: {
subject: 'החשבון שלך ב-Fluxer מתוכנן למחיקה',
body: `שלום {username},
החשבון שלך ב-Fluxer נקבע למחיקה קבועה.
תאריך מחיקה מתוכנן: {deletionDate, date, full} {deletionDate, time, short}
סיבה: {reason}
מדובר בפעולת אכיפה משמעותית. כל נתוני החשבון יימחקו לצמיתות בתאריך זה.
אם אתה מאמין שההחלטה שגויה, תוכל לשלוח ערעור ל-appeals@fluxer.app.
- צוות האבטחה של Fluxer`,
},
giftChargebackNotification: {
subject: 'הטבת Fluxer Premium שלך בוטלה',
body: `שלום {username},
אנו מודיעים כי הטבת ה-Fluxer Premium שקיבלת בוטלה עקב מחלוקת תשלום (chargeback) שהוגשה על ידי הרוכש המקורי.
ההטבות הוסרו מהחשבון שלך. פעולה זו בוצעה מכיוון שהתשלום בוטל/הוחזר.
לשאלות: support@fluxer.app
- צוות Fluxer`,
},
reportResolved: {
subject: 'הדיווח שלך ל-Fluxer נבדק',
body: `שלום {username},
הדיווח שלך (מזהה: {reportId}) נבדק על ידי צוות האבטחה שלנו.
תגובת צוות האבטחה:
{publicComment}
תודה על תרומתך לשמירה על בטיחות הקהילה. אנו מתייחסים ברצינות לכל דיווח ומעריכים את מעורבותך.
אם יש לך שאלות או חששות, פנה אלינו בכתובת safety@fluxer.app.
- צוות האבטחה של Fluxer`,
},
dsaReportVerification: {
subject: 'אמת את כתובת האימייל שלך לדיווח DSA',
body: `שלום,
השתמש בקוד האימות הבא כדי להגיש את דיווח חוק השירותים הדיגיטליים שלך ב-Fluxer:
{code}
קוד זה יפוג בתאריך {expiresAt, date, full} {expiresAt, time, short}.
אם לא ביקשת זאת, אנא התעלם מהודעה זו.
- צוות האבטחה של Fluxer`,
},
registrationApproved: {
subject: 'הרישום שלך ל-Fluxer אושר',
body: `שלום {username},
חדשות מעולות! הרישום שלך ל-Fluxer אושר.
אתה יכול להתחבר לאפליקציה בכתובת:
{channelsUrl}
ברוך הבא לקהילת Fluxer!
- צוות Fluxer`,
},
emailChangeRevert: {
subject: 'כתובת האימייל שלך ב-Fluxer השתנתה',
body: `היי {username},
כתובת האימייל של חשבון Fluxer שלך שונתה ל-{newEmail}.
אם את/ה ביצעת את השינוי, אין צורך לעשות דבר. אם לא, אפשר לבטל ולהגן על החשבון דרך הקישור הזה:
{revertUrl}
זה ישחזר את האימייל הקודם, ינתק אותך מכל ההתקנים, יסיר מספרי טלפון מקושרים, יבטל MFA וידרוש סיסמה חדשה.
- צוות האבטחה של Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const hi: EmailTranslations = {
passwordReset: {
subject: 'अपना Fluxer पासवर्ड रीसेट करें',
body: `नमस्ते {username},
आपने अपने Fluxer खाते का पासवर्ड रीसेट करने का अनुरोध किया है। कृपया नया पासवर्ड सेट करने के लिए नीचे दिए गए लिंक का पालन करें:
{resetUrl}
अगर आपने यह अनुरोध नहीं किया है, तो आप इस ईमेल को सुरक्षित रूप से अनदेखा कर सकते हैं।
यह लिंक 1 घंटे में समाप्त हो जाएगा।
- Fluxer टीम`,
},
emailVerification: {
subject: 'अपना Fluxer ईमेल पता सत्यापित करें',
body: `नमस्ते {username},
कृपया नीचे दिए गए लिंक पर क्लिक करके अपने Fluxer खाते का ईमेल पता सत्यापित करें:
{verifyUrl}
यदि आपने Fluxer खाता नहीं बनाया है, तो आप इस ईमेल को सुरक्षित रूप से अनदेखा कर सकते हैं।
यह लिंक 24 घंटों में समाप्त हो जाएगा।
- Fluxer टीम`,
},
ipAuthorization: {
subject: 'नई IP एड्रेस से लॉगिन को अधिकृत करें',
body: `नमस्ते {username},
हमने आपके Fluxer खाते में नई IP एड्रेस से लॉगिन करने का प्रयास पाया है:
IP एड्रेस: {ipAddress}
स्थान: {location}
यदि यह आपने किया है, तो कृपया नीचे दिए गए लिंक पर क्लिक करके इस IP को अधिकृत करें:
{authUrl}
यदि आपने लॉगिन करने का प्रयास नहीं किया है, तो कृपया तुरंत अपना पासवर्ड बदलें।
यह अधिकृत लिंक 30 मिनट में समाप्त हो जाएगा।
- Fluxer टीम`,
},
accountDisabledSuspicious: {
subject: 'आपका Fluxer खाता अस्थायी रूप से निष्क्रिय कर दिया गया है',
body: `नमस्ते {username},
संदिग्ध गतिविधि के कारण आपका Fluxer खाता अस्थायी रूप से निष्क्रिय कर दिया गया है।
{reason, select,
null {}
other {कारण: {reason}
}}अपना खाता दोबारा एक्सेस करने के लिए आपको पासवर्ड रीसेट करना होगा:
{forgotUrl}
पासवर्ड रीसेट करने के बाद आप फिर से लॉगिन कर पाएंगे।
यदि आपको लगता है कि यह कार्रवाई गलती से की गई है, तो कृपया हमारी सहायता टीम से संपर्क करें।
- Fluxer सुरक्षा टीम`,
},
accountTempBanned: {
subject: 'आपका Fluxer खाता अस्थायी रूप से निलंबित कर दिया गया है',
body: `नमस्ते {username},
आपका Fluxer खाता हमारे सेवा की शर्तों या सामुदायिक दिशानिर्देशों के उल्लंघन के कारण अस्थायी रूप से निलंबित किया गया है।
अवधि: {durationHours, plural,
=1 {1 घंटा}
other {# घंटे}
}
निलंबन समाप्त होगा: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
कारण: {reason}}
}
इस अवधि के दौरान आप अपने खाते तक पहुँच नहीं पाएंगे।
कृपया हमारे दस्तावेज़ देखें:
- सेवा की शर्तें: {termsUrl}
- सामुदायिक दिशानिर्देश: {guidelinesUrl}
यदि आपको लगता है कि यह कार्रवाई गलत या अनुचित है, तो आप appeals@fluxer.app पर ईमेल करके अपील कर सकते हैं। कृपया स्पष्ट रूप से बताएं कि आपको निर्णय गलत क्यों लगता है। हम आपकी अपील की समीक्षा करेंगे और अपना निर्णय भेजेंगे।
- Fluxer सुरक्षा टीम`,
},
accountScheduledDeletion: {
subject: 'आपका Fluxer खाता स्थायी रूप से हटाने के लिए निर्धारित किया गया है',
body: `नमस्ते {username},
आपका Fluxer खाता हमारी सेवा की शर्तों या सामुदायिक दिशानिर्देशों के उल्लंघन के कारण स्थायी रूप से हटाने के लिए निर्धारित किया गया है।
निर्धारित हटाने की तिथि: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
कारण: {reason}}
}
यह एक गंभीर अनुशासनात्मक कार्रवाई है। आपके खाते का डेटा निर्धारित तिथि पर स्थायी रूप से हटा दिया जाएगा।
कृपया हमारे दस्तावेज़ देखें:
- सेवा की शर्तें: {termsUrl}
- सामुदायिक दिशानिर्देश: {guidelinesUrl}
अपील प्रक्रिया:
यदि आपको लगता है कि यह कार्रवाई गलत या अनुचित है, तो आपके पास 30 दिन हैं appeals@fluxer.app पर अपील भेजने के लिए।
अपील में:
- स्पष्ट रूप से बताएं कि निर्णय गलत या अनुचित क्यों है
- कोई भी संबंधित साक्ष्य या संदर्भ प्रदान करें
Fluxer सुरक्षा टीम का एक सदस्य आपकी अपील की समीक्षा करेगा और अंतिम निर्णय होने तक हटाने को रोक भी सकता है।
- Fluxer सुरक्षा टीम`,
},
selfDeletionScheduled: {
subject: 'आपके Fluxer खाते को हटाने का समय निर्धारित कर दिया गया है',
body: `नमस्ते {username},
हमें खेद है कि आप जा रहे हैं! आपके Fluxer खाते को हटाने का समय निर्धारित कर दिया गया है।
निर्धारित हटाने की तिथि: {deletionDate, date, full} {deletionDate, time, short}
महत्वपूर्ण: आप {deletionDate, date, full} {deletionDate, time, short} से पहले कभी भी अपने खाते में फिर से लॉगिन करके हटाने को रद्द कर सकते हैं।
जाने से पहले:
उपयोगकर्ता सेटिंग्स में आपका गोपनीयता डैशबोर्ड आपको अनुमति देता है:
- प्लेटफ़ॉर्म पर अपने संदेश हटाएं
- जाने से पहले अपना महत्वपूर्ण डेटा निर्यात करें
कृपया ध्यान दें: एक बार जब आपका खाता हटा दिया जाता है, तो संदेश हटाना संभव नहीं होगा। यदि आप अपने संदेश हटाना चाहते हैं, तो कृपया खाता हटाए जाने से पहले ऐसा करें।
यदि आप अपना विचार बदलते हैं, तो बस दोबारा लॉगिन करें।
- Fluxer टीम`,
},
inactivityWarning: {
subject: 'निष्क्रियता के कारण आपका Fluxer खाता हटा दिया जाएगा',
body: `नमस्ते {username},
हमने देखा कि आपने 2 से अधिक वर्षों से अपने Fluxer खाते में लॉगिन नहीं किया है।
अंतिम लॉगिन: {lastActiveDate, date, full} {lastActiveDate, time, short}
हमारी डेटा प्रतिधारण नीति के अनुसार, निष्क्रिय खातों को स्वचालित रूप से हटाने के लिए निर्धारित किया जाता है। आपका खाता स्थायी रूप से हटाया जाएगा:
निर्धारित हटाने की तिथि: {deletionDate, date, full} {deletionDate, time, short}
खाता कैसे सुरक्षित रखें:
सिर्फ {loginUrl} पर जाकर हटाने की तिथि से पहले लॉगिन कर लें। आपको कुछ और करने की ज़रूरत नहीं है।
यदि आप लॉगिन नहीं करते:
- आपका खाता और उससे संबंधित सभी डेटा स्थायी रूप से हटा दिए जाएंगे
- आपके संदेश अनाम कर दिए जाएंगे (“Deleted User” के रूप में)
- यह कार्रवाई वापस नहीं ली जा सकती
क्या आप अपने संदेश हटाना चाहते हैं?
यदि आप अपने संदेश हटाना चाहते हैं, तो खाते के हटाए जाने से पहले लॉगिन करके गोपनीयता डैशबोर्ड का उपयोग करें।
हम आशा करते हैं कि आप फिर से Fluxer का उपयोग करेंगे!
- Fluxer टीम`,
},
harvestCompleted: {
subject: 'आपका Fluxer डेटा निर्यात तैयार है',
body: `नमस्ते {username},
आपका डेटा निर्यात पूरा हो गया है और डाउनलोड के लिए तैयार है!
निर्यात सारांश:
- कुल संदेश: {totalMessages, number}
- फ़ाइल आकार: {fileSizeMB} MB
- प्रारूप: JSON फ़ाइलों के साथ ZIP आर्काइव
डेटा डाउनलोड करें: {downloadUrl}
महत्वपूर्ण: यह डाउनलोड लिंक {expiresAt, date, full} {expiresAt, time, short} पर समाप्त हो जाएगा।
निर्यात में शामिल है:
- आपके सभी संदेश, चैनल के अनुसार व्यवस्थित
- चैनल मेटाडेटा
- आपकी प्रोफ़ाइल और खाता जानकारी
- गिल्ड सदस्यता और सेटिंग्स
- प्रमाणीकरण सत्र और सुरक्षा जानकारी
डेटा JSON प्रारूप में प्रदान किया गया है ताकि विश्लेषण आसान हो सके।
यदि आपके कोई प्रश्न हैं, तो support@fluxer.app पर संपर्क करें।
- Fluxer टीम`,
},
unbanNotification: {
subject: 'आपका Fluxer निलंबन हटा दिया गया है',
body: `नमस्ते {username},
अच्छी खबर! आपका Fluxer खाता निलंबन हटा दिया गया है।
कारण: {reason}
आप अब फिर से लॉगिन कर सकते हैं और Fluxer का उपयोग कर सकते हैं।
- Fluxer सुरक्षा टीम`,
},
scheduledDeletionNotification: {
subject: 'आपका Fluxer खाता हटाने के लिए निर्धारित किया गया है',
body: `नमस्ते {username},
आपका Fluxer खाता स्थायी रूप से हटाने के लिए निर्धारित किया गया है।
निर्धारित हटाने की तिथि: {deletionDate, date, full} {deletionDate, time, short}
कारण: {reason}
यह एक गंभीर कार्रवाई है। आपके खाते का डेटा निर्धारित तिथि पर हटाया जाएगा।
यदि आप इस निर्णय से असहमत हैं, तो appeals@fluxer.app पर संपर्क करें।
- Fluxer सुरक्षा टीम`,
},
giftChargebackNotification: {
subject: 'आपका Fluxer Premium उपहार रद्द कर दिया गया है',
body: `नमस्ते {username},
हम आपको सूचित कर रहे हैं कि आपका Fluxer Premium उपहार रद्द कर दिया गया है क्योंकि मूल खरीदार ने भुगतान विवाद (chargeback) दाखिल किया है।
आपके प्रीमियम लाभ हटा दिए गए हैं। यह क्रिया इसलिए की गई क्योंकि भुगतान को रद्द कर दिया गया था।
किसी भी प्रश्न के लिए support@fluxer.app पर संपर्क करें।
- Fluxer टीम`,
},
reportResolved: {
subject: 'आपकी Fluxer रिपोर्ट की समीक्षा कर ली गई है',
body: `नमस्ते {username},
आपकी रिपोर्ट (ID: {reportId}) की Fluxer सुरक्षा टीम द्वारा समीक्षा कर ली गई है।
सुरक्षा टीम की प्रतिक्रिया:
{publicComment}
Fluxer को सुरक्षित बनाए रखने में मदद करने के लिए धन्यवाद। हम सभी रिपोर्टों को गंभीरता से लेते हैं और आपके योगदान की सराहना करते हैं।
यदि आपको इस निर्णय के बारे में कोई चिंता है, तो safety@fluxer.app पर संपर्क करें।
- Fluxer सुरक्षा टीम`,
},
dsaReportVerification: {
subject: 'DSA रिपोर्ट के लिए अपने ईमेल को सत्यापित करें',
body: `नमस्ते,
Fluxer पर डिजिटल सेवा अधिनियम रिपोर्ट सबमिट करने के लिए निम्नलिखित सत्यापन कोड का उपयोग करें:
{code}
यह कोड {expiresAt, date, full} {expiresAt, time, short} पर समाप्त हो जाएगा।
यदि आपने यह अनुरोध नहीं किया है, तो कृपया इस ईमेल को अनदेखा करें।
- Fluxer सुरक्षा टीम`,
},
registrationApproved: {
subject: 'आपका Fluxer पंजीकरण स्वीकृत कर दिया गया है',
body: `नमस्ते {username},
शानदार खबर! आपका Fluxer पंजीकरण स्वीकृत कर दिया गया है।
अब आप Fluxer ऐप में लॉगिन कर सकते हैं:
{channelsUrl}
Fluxer समुदाय में आपका स्वागत है!
- Fluxer टीम`,
},
emailChangeRevert: {
subject: 'आपका Fluxer ईमेल बदल दिया गया है',
body: `नमस्ते {username},
आपके Fluxer खाते का ईमेल {newEmail} में बदल दिया गया है।
यदि यह बदलाव आपने किया है तो कोई कार्रवाई आवश्यक नहीं है। यदि नहीं, तो आप इस लिंक का उपयोग करके बदलाव को वापस ले सकते हैं और अपने खाते को सुरक्षित कर सकते हैं:
{revertUrl}
इससे आपका पिछला ईमेल बहाल होगा, आप सभी सत्रों से साइन-आउट हो जाएंगे, जुड़े हुए फ़ोन नंबर हट जाएंगे, MFA निष्क्रिय हो जाएगा, और नया पासवर्ड आवश्यक होगा।
- Fluxer सुरक्षा टीम`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const hr: EmailTranslations = {
passwordReset: {
subject: 'Resetirajte svoju Fluxer lozinku',
body: `Pozdrav {username},
Zatražili ste resetiranje lozinke za svoj Fluxer račun. Slijedite poveznicu u nastavku kako biste postavili novu lozinku:
{resetUrl}
Ako niste zatražili resetiranje lozinke, možete zanemariti ovu poruku.
Ova poveznica istječe za 1 sat.
- Fluxer tim`,
},
emailVerification: {
subject: 'Potvrdite svoju Fluxer adresu e-pošte',
body: `Pozdrav {username},
Molimo vas da potvrdite adresu e-pošte svog Fluxer računa klikom na poveznicu u nastavku:
{verifyUrl}
Ako niste izradili Fluxer račun, možete zanemariti ovu poruku.
Ova poveznica istječe za 24 sata.
- Fluxer tim`,
},
ipAuthorization: {
subject: 'Autorizirajte prijavu s nove IP adrese',
body: `Pozdrav {username},
Otkrili smo pokušaj prijave na vaš Fluxer račun s nove IP adrese:
IP adresa: {ipAddress}
Lokacija: {location}
Ako ste to bili vi, molimo vas da autorizirate ovu IP adresu klikom na poveznicu u nastavku:
{authUrl}
Ako se niste pokušali prijaviti, odmah promijenite svoju lozinku.
Ova autorizacijska poveznica istječe za 30 minuta.
- Fluxer tim`,
},
accountDisabledSuspicious: {
subject: 'Vaš Fluxer račun je privremeno onemogućen',
body: `Pozdrav {username},
Vaš Fluxer račun privremeno je onemogućen zbog sumnjive aktivnosti.
{reason, select,
null {}
other {Razlog: {reason}
}}Kako biste ponovno dobili pristup svom računu, morate resetirati lozinku:
{forgotUrl}
Nakon resetiranja lozinke moći ćete se ponovno prijaviti.
Ako smatrate da je ovo pogreška, molimo kontaktirajte naš tim podrške.
- Fluxer sigurnosni tim`,
},
accountTempBanned: {
subject: 'Vaš Fluxer račun je privremeno suspendiran',
body: `Pozdrav {username},
Vaš Fluxer račun privremeno je suspendiran zbog kršenja naših Uvjeta korištenja ili Smjernica zajednice.
Trajanje: {durationHours, plural,
=1 {1 sat}
other {# sati}
}
Suspendirano do: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Razlog: {reason}}
}
Tijekom ove suspenzije nećete moći pristupiti svom računu.
Preporučujemo da pregledate:
- Uvjete korištenja: {termsUrl}
- Smjernice zajednice: {guidelinesUrl}
Ako smatrate da je ova odluka pogrešna ili neopravdana, možete poslati žalbu na appeals@fluxer.app s ove e-mail adrese. Jasno objasnite zašto smatrate da je odluka pogrešna. Razmotrit ćemo vašu žalbu i poslati našu odluku.
- Fluxer sigurnosni tim`,
},
accountScheduledDeletion: {
subject: 'Vaš Fluxer račun je zakazan za brisanje',
body: `Pozdrav {username},
Vaš Fluxer račun zakazan je za trajno brisanje zbog kršenja naših Uvjeta korištenja ili Smjernica zajednice.
Zakazani datum brisanja: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Razlog: {reason}}
}
Ovo je ozbiljna mjera provedbe. Podaci vašeg računa bit će trajno izbrisani na zakazani datum.
Preporučujemo da pregledate:
- Uvjete korištenja: {termsUrl}
- Smjernice zajednice: {guidelinesUrl}
POSTUPAK ŽALBE:
Ako smatrate da je odluka pogrešna ili neopravdana, imate 30 dana da pošaljete žalbu na appeals@fluxer.app s ove e-mail adrese.
U žalbi:
- Jasno objasnite zašto smatrate da je odluka pogrešna ili neopravdana
- Pružite sve relevantne dokaze ili dodatni kontekst
Član Fluxer sigurnosnog tima pregledat će vašu žalbu i može odgoditi brisanje dok se ne donese konačna odluka.
- Fluxer sigurnosni tim`,
},
selfDeletionScheduled: {
subject: 'Brisanje vašeg Fluxer računa je zakazano',
body: `Pozdrav {username},
Žao nam je što odlazite! Brisanje vašeg Fluxer računa je zakazano.
Zakazani datum brisanja: {deletionDate, date, full} {deletionDate, time, short}
VAŽNO: Brisanje možete otkazati u bilo kojem trenutku prije {deletionDate, date, full} {deletionDate, time, short} jednostavnom prijavom u svoj račun.
PRIJE NEGO ODETE:
Nadzorna ploča privatnosti u korisničkim postavkama omogućuje vam da:
- Obrišete svoje poruke na platformi
- Izvezete važne podatke prije odlaska
Napomena: Kada se račun izbriše, nećete moći obrisati poruke. Ako želite obrisati svoje poruke, učinite to prije završetka brisanja računa.
Ako promijenite mišljenje, jednostavno se ponovno prijavite kako biste otkazali brisanje.
- Fluxer tim`,
},
inactivityWarning: {
subject: 'Vaš Fluxer račun bit će izbrisan zbog neaktivnosti',
body: `Pozdrav {username},
Primijetili smo da se niste prijavili u svoj Fluxer račun više od 2 godine.
Zadnja prijava: {lastActiveDate, date, full} {lastActiveDate, time, short}
U sklopu naše politike zadržavanja podataka, neaktivni računi automatski se zakazuju za brisanje. Vaš račun će biti trajno izbrisan:
Zakazani datum brisanja: {deletionDate, date, full} {deletionDate, time, short}
KAKO SAČUVATI SVOJ RAČUN:
Dovoljno je da se prijavite prije zakazanog datuma brisanja putem {loginUrl}. Nije potrebna dodatna radnja.
AKO SE NE PRIJAVITE:
- Vaš račun i svi povezani podaci bit će trajno izbrisani
- Vaše poruke bit će anonimizirane (prikazane kao „Izbrisani korisnik“)
- Ova radnja je nepovratna
ŽELITE OBRISATI SVOJE PORUKE?
Ako želite obrisati poruke prije nego što se račun izbriše, prijavite se i koristite nadzornu ploču privatnosti.
Nadamo se da ćemo vas ponovno vidjeti na Fluxeru!
- Fluxer tim`,
},
harvestCompleted: {
subject: 'Vaš izvoz Fluxer podataka je spreman',
body: `Pozdrav {username},
Vaš izvoz podataka je dovršen i spreman za preuzimanje!
Sažetak izvoza:
- Ukupan broj poruka: {totalMessages, number}
- Veličina datoteke: {fileSizeMB} MB
- Format: ZIP arhiva s JSON datotekama
Preuzmite svoje podatke: {downloadUrl}
VAŽNO: Ova poveznica za preuzimanje istječe {expiresAt, date, full} {expiresAt, time, short}
U izvozu se nalazi:
- Sve vaše poruke organizirane po kanalima
- Metapodaci kanala
- Vaš korisnički profil i informacije o računu
- Članstva u guildovima i postavke
- Autentifikacijske sesije i sigurnosni podaci
Podaci su organizirani u JSON formatu radi lakše analize.
Ako imate pitanja o izvozu podataka, kontaktirajte support@fluxer.app
- Fluxer tim`,
},
unbanNotification: {
subject: 'Suspencija vašeg Fluxer računa je ukinuta',
body: `Pozdrav {username},
Dobre vijesti! Suspencija vašeg Fluxer računa je ukinuta.
Razlog: {reason}
Sada se možete ponovno prijaviti i nastaviti koristiti Fluxer.
- Fluxer sigurnosni tim`,
},
scheduledDeletionNotification: {
subject: 'Vaš Fluxer račun je zakazan za brisanje',
body: `Pozdrav {username},
Vaš Fluxer račun zakazan je za trajno brisanje.
Zakazani datum brisanja: {deletionDate, date, full} {deletionDate, time, short}
Razlog: {reason}
Ovo je ozbiljna mjera provedbe. Vaši podaci bit će trajno izbrisani na navedeni datum.
Ako mislite da je odluka pogrešna, možete poslati žalbu na appeals@fluxer.app.
- Fluxer sigurnosni tim`,
},
giftChargebackNotification: {
subject: 'Vaš Fluxer Premium dar je opozvan',
body: `Pozdrav {username},
Obavještavamo vas da je vaš Fluxer Premium dar opozvan zbog povrata uplate (chargeback) koji je podnio izvorni kupac.
Premium pogodnosti su uklonjene s vašeg računa. Ovo je učinjeno jer je uplata za dar osporena i vraćena.
Ako imate pitanja, kontaktirajte support@fluxer.app.
- Fluxer tim`,
},
reportResolved: {
subject: 'Vaša Fluxer prijava je pregledana',
body: `Pozdrav {username},
Vaša prijava (ID: {reportId}) pregledana je od strane našeg sigurnosnog tima.
Odgovor sigurnosnog tima:
{publicComment}
Hvala vam što pomažete održavati Fluxer sigurnim za sve. Ozbiljno shvaćamo sve prijave i cijenimo vaš doprinos zajednici.
Ako imate pitanja ili brige u vezi ove odluke, kontaktirajte safety@fluxer.app.
- Fluxer sigurnosni tim`,
},
dsaReportVerification: {
subject: 'Potvrdite svoju e-poštu za DSA prijavu',
body: `Pozdrav,
Koristite sljedeći kod za potvrdu kako biste podnijeli prijavu prema Zakonu o digitalnim uslugama na Fluxer:
{code}
Ovaj kod istječe {expiresAt, date, full} {expiresAt, time, short}.
Ako niste zatražili ovo, možete zanemariti ovu poruku.
- Fluxer sigurnosni tim`,
},
registrationApproved: {
subject: 'Vaša Fluxer registracija je odobrena',
body: `Pozdrav {username},
Sjajne vijesti! Vaša Fluxer registracija je odobrena.
Sada se možete prijaviti u Fluxer aplikaciju ovdje:
{channelsUrl}
Dobro došli u Fluxer zajednicu!
- Fluxer tim`,
},
emailChangeRevert: {
subject: 'Tvoja Fluxer e-pošta je promijenjena',
body: `Bok {username},
E-pošta tvog Fluxer računa promijenjena je u {newEmail}.
Ako si ti napravio/la ovu promjenu, ne moraš ništa dalje raditi. Ako nisi, možeš je poništiti i osigurati račun putem ove poveznice:
{revertUrl}
Time će se vratiti prijašnja e-pošta, bit ćeš odjavljen/a svugdje, uklonit će se povezani telefonski brojevi, MFA će biti onemogućen i tražit će se nova lozinka.
- Fluxer sigurnosni tim`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const hu: EmailTranslations = {
passwordReset: {
subject: 'Állítsd vissza a Fluxer-jelszavad',
body: `Szia {username},
Jelszó-visszaállítást kértél a Fluxer-fiókodhoz. Kérjük, kövesd az alábbi hivatkozást, hogy új jelszót állíts be:
{resetUrl}
Ha nem te kérted a jelszó visszaállítását, egyszerűen hagyd figyelmen kívül ezt az e-mailt.
A hivatkozás 1 órán belül lejár.
- A Fluxer csapata`,
},
emailVerification: {
subject: 'Erősítsd meg a Fluxer e-mail címed',
body: `Szia {username},
Kérjük, erősítsd meg a Fluxer-fiókodhoz tartozó e-mail címed az alábbi hivatkozásra kattintva:
{verifyUrl}
Ha nem te hoztál létre Fluxer-fiókot, figyelmen kívül hagyhatod ezt az e-mailt.
A hivatkozás 24 órán belül lejár.
- A Fluxer csapata`,
},
ipAuthorization: {
subject: 'Engedélyezd a bejelentkezést új IP-címről',
body: `Szia {username},
Új IP-címről történő bejelentkezési kísérletet észleltünk a Fluxer-fiókodban:
IP-cím: {ipAddress}
Hely: {location}
Ha te próbáltál bejelentkezni, kattints az alábbi hivatkozásra az IP-cím engedélyezéséhez:
{authUrl}
Ha nem te voltál, azonnal változtasd meg a jelszavad.
Ez az engedélyezési hivatkozás 30 perc múlva lejár.
- A Fluxer csapata`,
},
accountDisabledSuspicious: {
subject: 'Fluxer-fiókod ideiglenesen letiltásra került',
body: `Szia {username},
Fluxer-fiókodat ideiglenesen letiltottuk gyanús aktivitás miatt.
{reason, select,
null {}
other {Indoklás: {reason}
}}A hozzáférés visszanyeréséhez vissza kell állítanod a jelszavad:
{forgotUrl}
A jelszó visszaállítása után ismét be tudsz jelentkezni.
Ha úgy gondolod, hogy a letiltás tévedés volt, kérjük, keresd fel ügyfélszolgálatunkat.
- A Fluxer biztonsági csapata`,
},
accountTempBanned: {
subject: 'Fluxer-fiókod ideiglenesen fel lett függesztve',
body: `Szia {username},
Fluxer-fiókodat ideiglenesen felfüggesztettük a Szolgáltatási feltételek vagy a Közösségi irányelvek megsértése miatt.
Időtartam: {durationHours, plural,
=1 {1 óra}
other {# óra}
}
Felfüggesztés vége: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Indoklás: {reason}}
}
Ebben az időszakban nem fogsz tudni hozzáférni a fiókodhoz.
Kérjük, tekintsd át:
- Szolgáltatási feltételek: {termsUrl}
- Közösségi irányelvek: {guidelinesUrl}
Ha úgy gondolod, hogy a döntés helytelen vagy indokolatlan, küldhetsz fellebbezést az appeals@fluxer.app címre erről az e-mail címről. Kérjük, részletesen magyarázd el, miért tartod hibásnak a döntést. Áttekintjük a fellebbezést és értesítünk az eredményről.
- A Fluxer biztonsági csapata`,
},
accountScheduledDeletion: {
subject: 'Fluxer-fiókod törlésre lett ütemezve',
body: `Szia {username},
Fluxer-fiókod törlését ütemeztük, mivel megsértetted a Szolgáltatási feltételeket vagy a Közösségi irányelveket.
Tervezett törlési időpont: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Indoklás: {reason}}
}
Ez egy komoly intézkedés. A fiókhoz tartozó összes adat véglegesen törlődik a megadott időpontban.
Kérjük, tekintsd át:
- Szolgáltatási feltételek: {termsUrl}
- Közösségi irányelvek: {guidelinesUrl}
FELLEBBEZÉSI FOLYAMAT:
Ha úgy véled, hogy a döntés hibás vagy indokolatlan, 30 napod van fellebbezést küldeni az appeals@fluxer.app címre erről az e-mail címről.
A fellebbezésben:
- Részletesen írd le, miért tartod helytelennek a döntést
- Adj meg minden releváns bizonyítékot vagy kontextust
A biztonsági csapat egy tagja felülvizsgálja a fellebbezést, és akár felfüggesztheti a törlést a végső döntésig.
- A Fluxer biztonsági csapata`,
},
selfDeletionScheduled: {
subject: 'Fluxer-fiókod törlése ütemezve lett',
body: `Szia {username},
Sajnáljuk, hogy távozol! Fluxer-fiókod törlését ütemeztük.
Tervezett törlési időpont: {deletionDate, date, full} {deletionDate, time, short}
FONTOS: Bármikor leállíthatod a törlést {deletionDate, date, full} {deletionDate, time, short} előtt, ha újra bejelentkezel a fiókodba.
MIELŐTT TOVÁBBLÉPNÉL:
A felhasználói beállításoknál található adatvédelmi irányítópult lehetővé teszi:
- Üzeneteid törlését a platformról
- Fontos adatok exportálását távozás előtt
Kérjük, vedd figyelembe: a fiók törlése után nem lehet visszamenőleg törölni az üzeneteket. Ha törölni szeretnéd őket, tedd meg a törlés véglegesítése előtt.
Ha meggondolod magad, csak jelentkezz be újra a törlés megszakításához.
- A Fluxer csapata`,
},
inactivityWarning: {
subject: 'Fluxer-fiókod inaktivitás miatt törlésre kerül',
body: `Szia {username},
Úgy tűnik, több mint 2 éve nem jelentkeztél be a Fluxer-fiókodba.
Utolsó bejelentkezés: {lastActiveDate, date, full} {lastActiveDate, time, short}
Adatmegőrzési irányelveink részeként az inaktív fiókok automatikusan törlésre ütemeződnek. A fiókodat véglegesen töröljük:
Tervezett törlési időpont: {deletionDate, date, full} {deletionDate, time, short}
HOGYAN TARTHATOD MEG A FIÓKODAT:
Egyszerűen jelentkezz be a {loginUrl} címen a törlési időpont előtt. Semmi mást nem kell tenned.
HA NEM JELENTKEZEL BE:
- A fiókod és minden kapcsolódó adat véglegesen törlődik
- Üzeneteid anonimizálva lesznek („Törölt felhasználó” megjelöléssel)
- Ez a művelet nem visszafordítható
SZERETNÉD TÖRÖLNI AZ ÜZENETEIDET?
Jelentkezz be, és használd az adatvédelmi irányítópultot a fiók törlése előtt.
Reméljük, visszatérsz a Fluxerre!
- A Fluxer csapata`,
},
harvestCompleted: {
subject: 'A Fluxer adat-exportod elkészült',
body: `Szia {username},
Az adataid exportálása sikeresen befejeződött, és most már letölthető!
Export összegzés:
- Üzenetek száma összesen: {totalMessages, number}
- Fájlméret: {fileSizeMB} MB
- Formátum: ZIP archívum JSON fájlokkal
Adataid letöltése: {downloadUrl}
FONTOS: A letöltési hivatkozás lejár ekkor: {expiresAt, date, full} {expiresAt, time, short}
Az export tartalmazza:
- Minden üzeneted, csatornánként rendezve
- Csatorna-metaadatok
- Felhasználói profilod és fiókinformációk
- Guild-tagságok és beállítások
- Hitelesítési munkamenetek és biztonsági információk
Az adatok JSON formátumban érkeznek, így könnyen feldolgozhatók.
Kérdés esetén írj a support@fluxer.app címre.
- A Fluxer csapata`,
},
unbanNotification: {
subject: 'Fluxer-fiókod felfüggesztése megszűnt',
body: `Szia {username},
Jó hír! Fluxer-fiókod felfüggesztését feloldottuk.
Indoklás: {reason}
Most ismét bejelentkezhetsz, és használhatod a Fluxert.
- A Fluxer biztonsági csapata`,
},
scheduledDeletionNotification: {
subject: 'Fluxer-fiókod törlésre ütemezve',
body: `Szia {username},
Fluxer-fiókod törlése véglegesen ütemezve lett.
Törlési időpont: {deletionDate, date, full} {deletionDate, time, short}
Indoklás: {reason}
Ez egy komoly lépés. A fiókod adatai véglegesen törlésre kerülnek.
Ha úgy gondolod, hogy a döntés helytelen, írj az appeals@fluxer.app címre.
- A Fluxer biztonsági csapata`,
},
giftChargebackNotification: {
subject: 'A Fluxer Premium ajándékod visszavonásra került',
body: `Szia {username},
Azért írunk, hogy tájékoztassunk: a Fluxer Premium ajándékot, amelyet beváltottál, visszavontuk egy fizetési vita (chargeback) miatt, amelyet az eredeti vásárló nyújtott be.
A Premium előnyöket eltávolítottuk a fiókodból. Ez azért történt, mert a fizetés visszafordításra került.
Ha kérdésed van, írj a support@fluxer.app címre.
- A Fluxer csapata`,
},
reportResolved: {
subject: 'A Fluxer jelentésedet felülvizsgáltuk',
body: `Szia {username},
A jelentésedet (azonosító: {reportId}) a biztonsági csapatunk átnézte.
A biztonsági csapat válasza:
{publicComment}
Köszönjük, hogy segítesz biztonságossá tenni a Fluxert mindenki számára. Nagyra értékeljük a közösséghez való hozzájárulásodat.
Ha kérdésed vagy aggályod van a döntéssel kapcsolatban, írj a safety@fluxer.app címre.
- A Fluxer biztonsági csapata`,
},
dsaReportVerification: {
subject: 'Erősítsd meg az e-mailedet a DSA jelentéshez',
body: `Szia,
Az alábbi ellenőrző kóddal küldheted be a Digitális Szolgáltatásokról szóló törvény szerinti jelentésedet a Fluxeren:
{code}
Ez a kód {expiresAt, date, full} {expiresAt, time, short} időpontban lejár.
Ha nem te kérted ezt, figyelmen kívül hagyhatod ezt az e-mailt.
- A Fluxer biztonsági csapata`,
},
registrationApproved: {
subject: 'Fluxer-regisztrációd jóváhagyva',
body: `Szia {username},
Nagyszerű hír! A Fluxer-regisztrációd jóvá lett hagyva.
Most már bejelentkezhetsz a Fluxer alkalmazásba:
{channelsUrl}
Üdvözlünk a Fluxer közösségben!
- A Fluxer csapata`,
},
emailChangeRevert: {
subject: 'Megváltozott a Fluxer e-mail címed',
body: `Szia {username},
A Fluxer-fiókod e-mail címe {newEmail} címre változott.
Ha te módosítottad, nincs további teendő. Ha nem, az alábbi linken visszavonhatod és biztosíthatod a fiókodat:
{revertUrl}
Ez visszaállítja a korábbi e-mail címet, mindenhol kijelentkeztet, eltávolítja a társított telefonszámokat, letiltja az MFA-t, és új jelszót kér.
- Fluxer biztonsági csapat`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const id: EmailTranslations = {
passwordReset: {
subject: 'Atur Ulang Kata Sandi Fluxer Anda',
body: `Halo {username},
Anda meminta untuk mengatur ulang kata sandi akun Fluxer Anda. Silakan ikuti tautan di bawah ini untuk membuat kata sandi baru:
{resetUrl}
Jika Anda tidak meminta pengaturan ulang kata sandi, Anda dapat mengabaikan email ini dengan aman.
Tautan ini akan kedaluwarsa dalam 1 jam.
- Tim Fluxer`,
},
emailVerification: {
subject: 'Verifikasi Alamat Email Fluxer Anda',
body: `Halo {username},
Silakan verifikasi alamat email akun Fluxer Anda dengan mengklik tautan berikut:
{verifyUrl}
Jika Anda tidak membuat akun Fluxer, Anda dapat mengabaikan email ini.
Tautan ini akan kedaluwarsa dalam 24 jam.
- Tim Fluxer`,
},
ipAuthorization: {
subject: 'Otorisasi Login dari Alamat IP Baru',
body: `Halo {username},
Kami mendeteksi percobaan login ke akun Fluxer Anda dari alamat IP baru:
Alamat IP: {ipAddress}
Lokasi: {location}
Jika ini adalah Anda, silakan otorisasi alamat IP ini dengan mengklik tautan di bawah:
{authUrl}
Jika Anda tidak mencoba login, segera ubah kata sandi Anda.
Tautan otorisasi ini akan kedaluwarsa dalam 30 menit.
- Tim Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Akun Fluxer Anda Dinonaktifkan Sementara',
body: `Halo {username},
Akun Fluxer Anda telah dinonaktifkan sementara karena aktivitas mencurigakan.
{reason, select,
null {}
other {Alasan: {reason}
}}Untuk mendapatkan kembali akses ke akun Anda, Anda harus mengatur ulang kata sandi:
{forgotUrl}
Setelah mengatur ulang kata sandi, Anda dapat login kembali.
Jika Anda yakin tindakan ini dilakukan karena kesalahan, silakan hubungi tim dukungan kami.
- Tim Keamanan Fluxer`,
},
accountTempBanned: {
subject: 'Akun Fluxer Anda Ditangguhkan Sementara',
body: `Halo {username},
Akun Fluxer Anda telah ditangguhkan sementara karena melanggar Ketentuan Layanan atau Pedoman Komunitas kami.
Durasi: {durationHours, plural,
=1 {1 jam}
other {# jam}
}
Ditangguhkan hingga: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Alasan: {reason}}
}
Selama periode ini, Anda tidak dapat mengakses akun Anda.
Kami menyarankan Anda meninjau:
- Ketentuan Layanan: {termsUrl}
- Pedoman Komunitas: {guidelinesUrl}
Jika Anda yakin keputusan ini tidak tepat atau tidak adil, Anda dapat mengirimkan banding ke appeals@fluxer.app dari email ini. Jelaskan dengan jelas mengapa Anda yakin keputusan tersebut salah. Kami akan meninjau banding Anda dan memberikan keputusan kami.
- Tim Keamanan Fluxer`,
},
accountScheduledDeletion: {
subject: 'Akun Fluxer Anda Dijadwalkan untuk Dihapus',
body: `Halo {username},
Akun Fluxer Anda dijadwalkan untuk dihapus secara permanen karena pelanggaran Ketentuan Layanan atau Pedoman Komunitas kami.
Tanggal penghapusan: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Alasan: {reason}}
}
Ini adalah tindakan penegakan serius. Data akun Anda akan dihapus secara permanen pada tanggal yang dijadwalkan.
Kami menyarankan Anda meninjau:
- Ketentuan Layanan: {termsUrl}
- Pedoman Komunitas: {guidelinesUrl}
PROSES BANDING:
Jika Anda yakin keputusan ini salah atau tidak adil, Anda memiliki waktu 30 hari untuk mengirimkan banding ke appeals@fluxer.app dari alamat email ini.
Dalam banding Anda:
- Jelaskan dengan jelas mengapa Anda yakin keputusan tersebut salah atau tidak adil
- Berikan bukti atau konteks tambahan yang relevan
Anggota Tim Keamanan Fluxer akan meninjau banding Anda dan dapat menunda penghapusan hingga keputusan akhir dibuat.
- Tim Keamanan Fluxer`,
},
selfDeletionScheduled: {
subject: 'Penghapusan Akun Fluxer Anda Telah Dijadwalkan',
body: `Halo {username},
Kami sedih melihat Anda pergi! Penghapusan akun Fluxer Anda telah dijadwalkan.
Tanggal penghapusan: {deletionDate, date, full} {deletionDate, time, short}
PENTING: Anda dapat membatalkan penghapusan ini kapan saja sebelum {deletionDate, date, full} {deletionDate, time, short} dengan cukup login kembali ke akun Anda.
SEBELUM ANDA PERGI:
Dasbor Privasi di Pengaturan Pengguna memungkinkan Anda untuk:
- Menghapus pesan Anda di platform
- Mengekspor data berharga sebelum pergi
Harap diperhatikan: Setelah akun Anda dihapus, Anda tidak dapat menghapus pesan apa pun. Jika Anda ingin menghapus pesan, lakukan melalui Dasbor Privasi sebelum akun dihapus.
Jika Anda berubah pikiran, cukup login kembali untuk membatalkan penghapusan.
- Tim Fluxer`,
},
inactivityWarning: {
subject: 'Akun Fluxer Anda Akan Dihapus Karena Tidak Aktif',
body: `Halo {username},
Kami melihat bahwa Anda belum login ke akun Fluxer Anda selama lebih dari 2 tahun.
Login terakhir: {lastActiveDate, date, full} {lastActiveDate, time, short}
Sebagai bagian dari kebijakan retensi data kami, akun tidak aktif dijadwalkan untuk dihapus secara otomatis. Akun Anda akan dihapus secara permanen pada:
Tanggal penghapusan: {deletionDate, date, full} {deletionDate, time, short}
CARA MENCEGAH PENGHAPUSAN:
Cukup login ke akun Anda di {loginUrl} sebelum tanggal penghapusan untuk membatalkan penghapusan otomatis ini.
JIKA ANDA TIDAK LOGIN:
- Akun dan semua data terkait akan dihapus secara permanen
- Pesan Anda akan dianonimkan (“Pengguna Terhapus”)
- Tindakan ini tidak dapat dibatalkan
INGIN MENGHAPUS PESAN ANDA?
Jika Anda ingin menghapus pesan sebelum akun dihapus, silakan login dan gunakan Dasbor Privasi di Pengaturan Pengguna.
Kami harap Anda kembali lagi ke Fluxer!
- Tim Fluxer`,
},
harvestCompleted: {
subject: 'Ekspor Data Fluxer Anda Siap',
body: `Halo {username},
Ekspor data Anda telah selesai dan siap diunduh!
Ringkasan Ekspor:
- Total pesan: {totalMessages, number}
- Ukuran file: {fileSizeMB} MB
- Format: Arsip ZIP berisi file JSON
Unduh data Anda: {downloadUrl}
PENTING: Tautan unduhan ini akan kedaluwarsa pada {expiresAt, date, full} {expiresAt, time, short}
Apa yang termasuk dalam ekspor Anda:
- Semua pesan Anda yang diatur berdasarkan kanal
- Metadata kanal
- Profil pengguna dan informasi akun Anda
- Keanggotaan guild dan pengaturan
- Sesi autentikasi dan informasi keamanan
Data disediakan dalam format JSON agar mudah dianalisis.
Jika Anda memiliki pertanyaan, silakan hubungi support@fluxer.app
- Tim Fluxer`,
},
unbanNotification: {
subject: 'Suspensi Akun Fluxer Anda Telah Dicabut',
body: `Halo {username},
Kabar baik! Suspensi akun Fluxer Anda telah dicabut.
Alasan: {reason}
Anda sekarang dapat login kembali dan melanjutkan penggunaan Fluxer.
- Tim Keamanan Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Akun Fluxer Anda Dijadwalkan untuk Dihapus',
body: `Halo {username},
Akun Fluxer Anda telah dijadwalkan untuk dihapus secara permanen.
Tanggal penghapusan: {deletionDate, date, full} {deletionDate, time, short}
Alasan: {reason}
Ini adalah tindakan penegakan serius. Data akun Anda akan dihapus secara permanen pada tanggal tersebut.
Jika Anda merasa keputusan ini salah, Anda dapat mengajukan banding melalui appeals@fluxer.app dari email ini.
- Tim Keamanan Fluxer`,
},
giftChargebackNotification: {
subject: 'Hadiah Fluxer Premium Anda Dicabut',
body: `Halo {username},
Kami ingin memberi tahu Anda bahwa hadiah Fluxer Premium yang Anda tukarkan telah dicabut karena sengketa pembayaran (chargeback) yang diajukan oleh pembeli asli.
Manfaat premium Anda telah dihapus dari akun. Tindakan ini dilakukan karena pembayaran hadiah dibatalkan.
Jika Anda memiliki pertanyaan, hubungi support@fluxer.app.
- Tim Fluxer`,
},
reportResolved: {
subject: 'Laporan Fluxer Anda Telah Ditinjau',
body: `Halo {username},
Laporan Anda (ID: {reportId}) telah ditinjau oleh Tim Keamanan kami.
Tanggapan dari Tim Keamanan:
{publicComment}
Terima kasih telah membantu menjaga Fluxer tetap aman untuk semua orang. Kami menghargai kontribusi Anda bagi komunitas.
Jika Anda memiliki pertanyaan atau kekhawatiran mengenai keputusan ini, hubungi safety@fluxer.app.
- Tim Keamanan Fluxer`,
},
dsaReportVerification: {
subject: 'Verifikasi email Anda untuk laporan DSA',
body: `Halo,
Gunakan kode verifikasi berikut untuk mengirimkan laporan Digital Services Act Anda di Fluxer:
{code}
Kode ini kedaluwarsa pada {expiresAt, date, full} {expiresAt, time, short}.
Jika Anda tidak meminta ini, harap abaikan email ini.
- Tim Keamanan Fluxer`,
},
registrationApproved: {
subject: 'Pendaftaran Fluxer Anda Telah Disetujui',
body: `Halo {username},
Kabar baik! Pendaftaran Anda di Fluxer telah disetujui.
Anda sekarang dapat login ke aplikasi Fluxer di:
{channelsUrl}
Selamat datang di komunitas Fluxer!
- Tim Fluxer`,
},
emailChangeRevert: {
subject: 'Email Fluxer kamu telah diubah',
body: `Halo {username},
Email akun Fluxer kamu telah diubah menjadi {newEmail}.
Jika kamu yang melakukan perubahan ini, tidak perlu tindakan lain. Jika bukan, kamu bisa membatalkannya dan mengamankan akun lewat tautan ini:
{revertUrl}
Ini akan memulihkan email sebelumnya, mengeluarkanmu dari semua sesi, menghapus nomor telepon terhubung, menonaktifkan MFA, dan meminta kata sandi baru.
- Tim Keamanan Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const it: EmailTranslations = {
passwordReset: {
subject: 'Reimposta la tua password Fluxer',
body: `Ciao {username},
Hai richiesto di reimpostare la password del tuo account Fluxer. Segui il link qui sotto per impostare una nuova password:
{resetUrl}
Se non hai richiesto tu questa reimpostazione, puoi ignorare questa email in sicurezza.
Questo link scadrà tra 1 ora.
- Il team Fluxer`,
},
emailVerification: {
subject: 'Verifica il tuo indirizzo email Fluxer',
body: `Ciao {username},
Per favore verifica l'indirizzo email associato al tuo account Fluxer cliccando sul link qui sotto:
{verifyUrl}
Se non hai creato un account Fluxer, puoi ignorare questa email.
Questo link scadrà tra 24 ore.
- Il team Fluxer`,
},
ipAuthorization: {
subject: "Autorizza l'accesso da un nuovo indirizzo IP",
body: `Ciao {username},
Abbiamo rilevato un tentativo di accesso al tuo account Fluxer da un nuovo indirizzo IP:
Indirizzo IP: {ipAddress}
Località: {location}
Se sei stato tu, autorizza questo indirizzo IP cliccando sul link:
{authUrl}
Se non hai tentato di accedere, modifica subito la tua password.
Questo link di autorizzazione scadrà tra 30 minuti.
- Il team Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Il tuo account Fluxer è stato temporaneamente disabilitato',
body: `Ciao {username},
Il tuo account Fluxer è stato temporaneamente disabilitato a causa di attività sospette.
{reason, select,
null {}
other {Motivo: {reason}
}}Per riottenere l'accesso al tuo account, devi reimpostare la password:
{forgotUrl}
Dopo aver reimpostato la password, potrai accedere nuovamente.
Se ritieni che questa misura sia stata presa per errore, contatta il nostro team di supporto.
- Il team Sicurezza Fluxer`,
},
accountTempBanned: {
subject: 'Il tuo account Fluxer è stato temporaneamente sospeso',
body: `Ciao {username},
Il tuo account Fluxer è stato temporaneamente sospeso per violazione dei nostri Termini di servizio o Linee guida della community.
Durata: {durationHours, plural,
=1 {1 ora}
other {# ore}
}
Sospeso fino al: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Durante questo periodo non potrai accedere al tuo account.
Ti invitiamo a consultare:
- Termini di servizio: {termsUrl}
- Linee guida della community: {guidelinesUrl}
Se ritieni che questa decisione sia errata o ingiustificata, puoi inviare un ricorso a appeals@fluxer.app da questo indirizzo email. Ti chiediamo di spiegare chiaramente perché ritieni che la decisione sia sbagliata. Esamineremo il ricorso e risponderemo con la nostra valutazione.
- Il team Sicurezza Fluxer`,
},
accountScheduledDeletion: {
subject: "Il tuo account Fluxer è programmato per l'eliminazione",
body: `Ciao {username},
Il tuo account Fluxer è stato programmato per l'eliminazione permanente a causa della violazione dei nostri Termini di servizio o Linee guida della community.
Data di eliminazione programmata: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Questa è una misura disciplinare seria. I dati del tuo account verranno eliminati definitivamente alla data indicata.
Ti invitiamo a consultare:
- Termini di servizio: {termsUrl}
- Linee guida della community: {guidelinesUrl}
PROCESSO DI RICORSO:
Se ritieni che la decisione sia errata o ingiustificata, hai 30 giorni di tempo per inviare un ricorso a appeals@fluxer.app da questo indirizzo email.
Nel tuo ricorso:
- Spiega chiaramente perché ritieni che la decisione sia sbagliata
- Fornisci eventuali prove o contesto rilevante
Un membro del team Sicurezza Fluxer esaminerà il ricorso e potrà sospendere l'eliminazione fino alla decisione finale.
- Il team Sicurezza Fluxer`,
},
selfDeletionScheduled: {
subject: "L'eliminazione del tuo account Fluxer è stata programmata",
body: `Ciao {username},
Ci dispiace vederti andare via! L'eliminazione del tuo account Fluxer è stata programmata.
Data di eliminazione programmata: {deletionDate, date, full} {deletionDate, time, short}
IMPORTANTE: Puoi annullare questa eliminazione in qualsiasi momento prima del {deletionDate, date, full} {deletionDate, time, short} semplicemente accedendo di nuovo al tuo account.
PRIMA DI ANDARE:
La tua Dashboard della privacy nelle Impostazioni utente ti consente di:
- Eliminare i tuoi messaggi sulla piattaforma
- Estrarre i tuoi dati importanti prima di lasciare
Nota: Una volta eliminato l'account, non sarà più possibile eliminare i tuoi messaggi. Se desideri farlo, fallo prima della data di eliminazione.
Se cambi idea, effettua nuovamente l'accesso per annullare l'eliminazione.
- Il team Fluxer`,
},
inactivityWarning: {
subject: 'Il tuo account Fluxer verrà eliminato per inattività',
body: `Ciao {username},
Abbiamo notato che non accedi al tuo account Fluxer da oltre 2 anni.
Ultimo accesso: {lastActiveDate, date, full} {lastActiveDate, time, short}
In base alla nostra politica di conservazione dei dati, gli account inattivi vengono automaticamente programmati per l'eliminazione. Il tuo account verrà eliminato definitivamente il:
Data di eliminazione programmata: {deletionDate, date, full} {deletionDate, time, short}
COME MANTENERE IL TUO ACCOUNT:
È sufficiente accedere al tuo account all'indirizzo {loginUrl} prima della data di eliminazione per annullare questo processo automatico.
SE NON ACCEDI:
- Il tuo account e tutti i dati associati verranno eliminati in modo permanente
- I tuoi messaggi verranno anonimizzati (attribuiti a “Utente eliminato”)
- Questa azione è irreversibile
VUOI ELIMINARE I TUOI MESSAGGI?
Se desideri eliminare i tuoi messaggi prima che il tuo account venga rimosso, accedi e utilizza la Dashboard della privacy.
Speriamo di rivederti presto su Fluxer!
- Il team Fluxer`,
},
harvestCompleted: {
subject: 'La tua esportazione dei dati Fluxer è pronta',
body: `Ciao {username},
La tua esportazione dei dati è stata completata ed è pronta per il download!
Riepilogo dell'esportazione:
- Numero totale di messaggi: {totalMessages, number}
- Dimensione del file: {fileSizeMB} MB
- Formato: Archivio ZIP con file JSON
Scarica i tuoi dati: {downloadUrl}
IMPORTANTE: Questo link per il download scadrà il {expiresAt, date, full} {expiresAt, time, short}
Cosa è incluso nell'esportazione:
- Tutti i tuoi messaggi organizzati per canale
- Metadati dei canali
- Il tuo profilo utente e informazioni sull'account
- Impostazioni e appartenenze ai server (guild)
- Sessioni di autenticazione e informazioni sulla sicurezza
I dati sono forniti in formato JSON per facilitare l'analisi.
Se hai domande sulla tua esportazione, contatta support@fluxer.app
- Il team Fluxer`,
},
unbanNotification: {
subject: 'La sospensione del tuo account Fluxer è stata revocata',
body: `Ciao {username},
Buone notizie! La sospensione del tuo account Fluxer è stata revocata.
Motivo: {reason}
Ora puoi accedere nuovamente e continuare a utilizzare Fluxer.
- Il team Sicurezza Fluxer`,
},
scheduledDeletionNotification: {
subject: "Il tuo account Fluxer è programmato per l'eliminazione",
body: `Ciao {username},
Il tuo account Fluxer è stato programmato per l'eliminazione permanente.
Data di eliminazione programmata: {deletionDate, date, full} {deletionDate, time, short}
Motivo: {reason}
Questa è una misura disciplinare seria. I dati del tuo account verranno eliminati definitivamente.
Se ritieni che questa decisione sia incorretta, puoi inviare un ricorso a appeals@fluxer.app.
- Il team Sicurezza Fluxer`,
},
giftChargebackNotification: {
subject: 'Il tuo regalo Fluxer Premium è stato revocato',
body: `Ciao {username},
Ti informiamo che il regalo Fluxer Premium che hai riscattato è stato revocato a causa di una contestazione di pagamento (chargeback) presentata dall'acquirente originale.
I tuoi vantaggi Premium sono stati rimossi dal tuo account. Ciò è avvenuto perché il pagamento è stato annullato.
Per eventuali domande, contatta support@fluxer.app.
- Il team Fluxer`,
},
reportResolved: {
subject: 'La tua segnalazione Fluxer è stata esaminata',
body: `Ciao {username},
La tua segnalazione (ID: {reportId}) è stata esaminata dal nostro Team Sicurezza.
Risposta del Team Sicurezza:
{publicComment}
Grazie per aver contribuito a mantenere Fluxer un ambiente sicuro. Apprezziamo il tuo contributo alla nostra community.
Per qualsiasi domanda o dubbio, contatta safety@fluxer.app.
- Il team Sicurezza Fluxer`,
},
dsaReportVerification: {
subject: 'Verifica la tua email per una segnalazione DSA',
body: `Salve,
Utilizza il seguente codice di verifica per inviare la tua segnalazione ai sensi del Digital Services Act su Fluxer:
{code}
Questo codice scade il {expiresAt, date, full} {expiresAt, time, short}.
Se non hai richiesto questa verifica, ignora questa email.
- Il team Sicurezza Fluxer`,
},
registrationApproved: {
subject: 'La tua registrazione Fluxer è stata approvata',
body: `Ciao {username},
Ottime notizie! La tua registrazione a Fluxer è stata approvata.
Ora puoi accedere all'app Fluxer qui:
{channelsUrl}
Benvenuto nella community Fluxer!
- Il team Fluxer`,
},
emailChangeRevert: {
subject: 'La tua email Fluxer è stata modificata',
body: `Ciao {username},
L'email del tuo account Fluxer è stata cambiata in {newEmail}.
Se hai effettuato tu questa modifica, non devi fare altro. In caso contrario, puoi annullarla e mettere al sicuro il tuo account con questo link:
{revertUrl}
Questo ripristinerà la tua email precedente, ti disconnetterà ovunque, rimuoverà i numeri di telefono associati, disabiliterà l'MFA e richiederà una nuova password.
- Team Sicurezza Fluxer`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const ja: EmailTranslations = {
passwordReset: {
subject: 'Fluxerフラクサーパスワードのリセット',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントのパスワード再設定がリクエストされました。以下のリンクから新しいパスワードを設定してください。
{resetUrl}
このパスワード再設定に心当たりがない場合は、このメールは無視していただいて問題ありません。
このリンクは1時間後に有効期限が切れます。
- Fluxer チーム`,
},
emailVerification: {
subject: 'Fluxerフラクサーメールアドレスの確認',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントのメールアドレス確認のため、以下のリンクをクリックしてください。
{verifyUrl}
もし Fluxer アカウントを作成していない場合は、このメールを無視して問題ありません。
このリンクは24時間後に有効期限が切れます。
- Fluxer チーム`,
},
ipAuthorization: {
subject: '新しい IP アドレスからのログインを承認してくださいFluxer',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントに、新しい IP アドレスからのログイン試行が検出されました。
IP アドレス: {ipAddress}
場所: {location}
ご本人の場合は、以下のリンクをクリックしてこの IP アドレスを承認してください。
{authUrl}
心当たりがない場合は、ただちにパスワードを変更してください。
この承認リンクは30分後に有効期限が切れます。
- Fluxer チーム`,
},
accountDisabledSuspicious: {
subject: 'Fluxerフラクサーアカウントが一時的に無効化されました',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントで不審な活動が検出されたため、一時的にアカウントを無効化しました。
{reason, select,
null {}
other {理由: {reason}
}}アカウントへ再びアクセスするには、パスワードをリセットしてください。
{forgotUrl}
パスワードをリセットすると、再度ログインできるようになります。
この処理に心当たりがない場合は、サポートチームまでご連絡ください。
- Fluxer セーフティチーム`,
},
accountTempBanned: {
subject: 'Fluxerフラクサーアカウントが一時的に停止されました',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントが、サービス利用規約またはコミュニティガイドラインへの違反により一時停止されました。
停止期間: {durationHours, plural,
=1 {1時間}
other {#時間}
}
停止解除予定: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {理由: {reason}}
}
この期間中はアカウントへアクセスできません。
以下の内容をご確認ください:
- 利用規約: {termsUrl}
- コミュニティガイドライン: {guidelinesUrl}
もしこの措置が誤っている、または不当だと感じる場合は、appeals@fluxer.app までメールをお送りください。
その際、判断が誤っていると思われる理由を明確に記載してください。
- Fluxer セーフティチーム`,
},
accountScheduledDeletion: {
subject: 'Fluxerフラクサーアカウントが削除予定となっています',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントが、利用規約またはコミュニティガイドライン違反により永久削除の対象となりました。
削除予定日時: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {理由: {reason}}
}
これは重大な措置です。アカウントデータは削除予定日時に完全に削除されます。
以下をご確認ください:
- 利用規約: {termsUrl}
- コミュニティガイドライン: {guidelinesUrl}
【異議申し立て手続き】
この決定が誤っている、または不当であると感じる場合は、30日以内に appeals@fluxer.app までメールをお送りください。
メール内容には以下を含めてください:
- なぜ決定が誤りまたは不当だと考えるのか
- 関連する証拠や背景情報
Fluxer セーフティチームが審査し、最終判断が下るまで削除が保留になる場合があります。
- Fluxer セーフティチーム`,
},
selfDeletionScheduled: {
subject: 'Fluxerフラクサーアカウント削除がスケジュールされました',
body: `こんにちは、{username} さん
ご利用ありがとうございましたFluxerフラクサーアカウントの削除がスケジュールされました。
削除予定日時: {deletionDate, date, full} {deletionDate, time, short}
重要: 上記の日時より前に再ログインすれば、削除をいつでも取り消すことができます。
【退会前にできること】
ユーザー設定内のプライバシーダッシュボードでは、以下が可能です:
- プラットフォーム上の自分のメッセージ削除
- データのエクスポート
注意: アカウント削除後は、メッセージを削除することはできません。必要な場合は削除前に行ってください。
もし気が変わった場合は、再度ログインするだけで削除を取り消せます。
- Fluxer チーム`,
},
inactivityWarning: {
subject: 'Fluxerフラクサーアカウントが長期間の未使用により削除されます',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントに2年以上ログインしていないことを確認しました。
最終ログイン日時: {lastActiveDate, date, full} {lastActiveDate, time, short}
データ保持ポリシーに基づき、長期間使用されていないアカウントは自動的に削除対象となります。
削除予定日時: {deletionDate, date, full} {deletionDate, time, short}
【アカウントを保持する方法】
削除予定日時より前に {loginUrl} からログインすれば、自動削除をキャンセルできます。
【ログインしなかった場合】
- アカウントとすべてのデータは永久に削除されます
- メッセージは匿名化されます(「削除されたユーザー」として表示)
- この操作は取り消せません
【メッセージを削除したい場合】
アカウント削除前にログインし、プライバシーダッシュボードをご利用ください。
Fluxerに戻ってきていただけることを願っています
- Fluxer チーム`,
},
harvestCompleted: {
subject: 'Fluxerフラクサーデータエクスポートの準備ができました',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントのデータエクスポートが完了し、ダウンロード可能になりました
エクスポート概要:
- 合計メッセージ数: {totalMessages, number}
- ファイルサイズ: {fileSizeMB} MB
- 形式: JSON ファイルを含む ZIP アーカイブ
データをダウンロード: {downloadUrl}
重要: このダウンロードリンクは {expiresAt, date, full} {expiresAt, time, short} に失効します。
エクスポートに含まれる内容:
- チャンネル別に整理されたすべてのメッセージ
- チャンネルメタデータ
- ユーザープロフィールとアカウント情報
- ギルドメンバーシップと設定
- 認証セッションとセキュリティ情報
データは JSON 形式で整理されているため、解析しやすくなっています。
ご不明点があれば support@fluxer.app までご連絡ください。
- Fluxer チーム`,
},
unbanNotification: {
subject: 'Fluxerフラクサーアカウントの停止が解除されました',
body: `こんにちは、{username} さん
朗報ですFluxerフラクサーアカウントの停止措置が解除されました。
理由: {reason}
再ログインして、Fluxer の利用を再開できます。
- Fluxer セーフティチーム`,
},
scheduledDeletionNotification: {
subject: 'Fluxerフラクサーアカウントが削除予定です',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントが永久削除の対象となりました。
削除予定日時: {deletionDate, date, full} {deletionDate, time, short}
理由: {reason}
これは重大な措置です。アカウントデータは削除予定日時に完全削除されます。
この措置に異議がある場合は、appeals@fluxer.app までメールをお送りください。
- Fluxer セーフティチーム`,
},
giftChargebackNotification: {
subject: 'FluxerフラクサーPremium ギフトが取り消されました',
body: `こんにちは、{username} さん
ご利用の FluxerフラクサーPremium ギフトが、購入者による支払い異議申し立て(チャージバック)により取り消されました。
これに伴い、Premium 特典はアカウントから削除されました。
ご不明点があれば support@fluxer.app までご連絡ください。
- Fluxer チーム`,
},
reportResolved: {
subject: 'Fluxerフラクサーへのご報告内容が審査されました',
body: `こんにちは、{username} さん
あなたの報告ID: {reportId})が Fluxer セーフティチームによって審査されました。
セーフティチームからの回答:
{publicComment}
Fluxer を安全な場所に保つためご協力いただき、ありがとうございます。
私たちはすべての報告を真剣に受け止めています。
ご不明点や懸念があれば safety@fluxer.app までご連絡ください。
- Fluxer セーフティチーム`,
},
dsaReportVerification: {
subject: 'DSA 報告のためのメールアドレス確認',
body: `こんにちは
Fluxer でデジタルサービス法に基づく報告を送信するため、以下の確認コードをご使用ください:
{code}
このコードは {expiresAt, date, full} {expiresAt, time, short} に有効期限が切れます。
このリクエストに心当たりがない場合は、このメールを無視してください。
- Fluxer セーフティチーム`,
},
registrationApproved: {
subject: 'Fluxerフラクサーへの登録が承認されました',
body: `こんにちは、{username} さん
嬉しいお知らせですFluxerフラクサーへの登録が承認されました。
以下から Fluxer アプリにログインできます:
{channelsUrl}
Fluxer コミュニティへようこそ!
- Fluxer チーム`,
},
emailChangeRevert: {
subject: 'Fluxerフラクサーメールアドレスが変更されました',
body: `こんにちは、{username} さん
Fluxerフラクサーアカウントのメールアドレスが {newEmail} に変更されました。
この変更に心当たりがある場合は、何もする必要はありません。もし身に覚えがない場合は、以下のリンクから元に戻してアカウントを保護してください。
{revertUrl}
これにより以前のメールアドレスが復元され、すべてのセッションからサインアウトされ、紐づく電話番号が削除され、MFAが無効になり、新しいパスワードの設定が必要になります。
- Fluxer セキュリティチーム`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const ko: EmailTranslations = {
passwordReset: {
subject: 'Fluxer 비밀번호 재설정',
body: `안녕하세요, {username}님.
Fluxer 계정의 비밀번호 재설정 요청이 접수되었습니다. 아래 링크를 통해 새 비밀번호를 설정해주세요.
{resetUrl}
비밀번호 재설정을 요청하지 않으셨다면, 이 이메일은 무시하셔도 안전합니다.
이 링크는 1시간 후 만료됩니다.
- Fluxer 팀`,
},
emailVerification: {
subject: 'Fluxer 이메일 주소를 확인해주세요',
body: `안녕하세요, {username}님.
아래 링크를 클릭하여 Fluxer 계정에 등록된 이메일 주소를 확인해주세요.
{verifyUrl}
Fluxer 계정을 생성하지 않으셨다면, 이 이메일은 무시하셔도 됩니다.
이 링크는 24시간 후 만료됩니다.
- Fluxer 팀`,
},
ipAuthorization: {
subject: '새 IP 주소에서의 로그인 승인',
body: `안녕하세요, {username}님.
새로운 IP 주소에서 Fluxer 계정으로 로그인 시도가 감지되었습니다.
IP 주소: {ipAddress}
위치: {location}
본인이 맞다면 아래 링크를 클릭하여 이 IP 주소를 승인해주세요.
{authUrl}
로그인을 시도하지 않으셨다면 즉시 비밀번호를 변경하시는 것을 권장드립니다.
이 승인 링크는 30분 후 만료됩니다.
- Fluxer 팀`,
},
accountDisabledSuspicious: {
subject: 'Fluxer 계정이 일시적으로 비활성화되었습니다',
body: `안녕하세요, {username}님.
의심스러운 활동이 감지되어 Fluxer 계정이 일시적으로 비활성화되었습니다.
{reason, select,
null {}
other {사유: {reason}
}}계정에 다시 접근하시려면 비밀번호를 재설정해야 합니다.
{forgotUrl}
비밀번호 재설정이 완료되면 다시 로그인하실 수 있습니다.
이 조치가 실수라고 생각되면 고객 지원 팀에 문의해주세요.
- Fluxer 안전팀`,
},
accountTempBanned: {
subject: 'Fluxer 계정이 일시적으로 정지되었습니다',
body: `안녕하세요, {username}님.
서비스 이용약관 또는 커뮤니티 가이드라인 위반으로 인해 Fluxer 계정이 일시적으로 정지되었습니다.
정지 기간: {durationHours, plural,
=1 {1시간}
other {#시간}
}
정지 해제 예정: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {사유: {reason}}
}
정지 기간 동안에는 계정에 접근하실 수 없습니다.
아래 내용을 확인해주시기 바랍니다.
- 이용약관: {termsUrl}
- 커뮤니티 가이드라인: {guidelinesUrl}
해당 조치가 잘못되었거나 부당하다고 생각되면, 이 이메일 주소에서 appeals@fluxer.app 로 이의 제기 메일을 보내실 수 있습니다.
왜 잘못된 결정이라고 생각하는지 자세히 설명해주시면, 검토 후 결과를 회신드리겠습니다.
- Fluxer 안전팀`,
},
accountScheduledDeletion: {
subject: 'Fluxer 계정이 삭제 예정 상태입니다',
body: `안녕하세요, {username}님.
서비스 이용약관 또는 커뮤니티 가이드라인 위반으로 인해 Fluxer 계정이 영구 삭제될 예정입니다.
예정된 삭제 일시: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {사유: {reason}}
}
이는 매우 중대한 조치이며, 예정된 시점에 계정 데이터는 완전히 삭제됩니다.
아래 문서를 다시 한 번 확인해주시기 바랍니다.
- 이용약관: {termsUrl}
- 커뮤니티 가이드라인: {guidelinesUrl}
[이의 제기 절차]
이 결정이 잘못되었거나 부당하다고 생각되면, 이 이메일 주소에서 30일 이내에 appeals@fluxer.app 로 이의 제기 메일을 보내실 수 있습니다.
메일에는 다음 내용을 포함해 주세요.
- 결정이 잘못되었다고 생각하는 구체적인 이유
- 관련 증거나 추가 설명
Fluxer 안전팀이 이의를 검토하며, 최종 결정이 내려질 때까지 삭제가 보류될 수 있습니다.
- Fluxer 안전팀`,
},
selfDeletionScheduled: {
subject: 'Fluxer 계정 삭제가 예약되었습니다',
body: `안녕하세요, {username}님.
떠나시게 되어 아쉽습니다. Fluxer 계정 삭제가 예약되었습니다.
예약된 삭제 일시: {deletionDate, date, full} {deletionDate, time, short}
중요: {deletionDate, date, full} {deletionDate, time, short} 이전에 계정으로 다시 로그인하시면 언제든지 삭제를 취소하실 수 있습니다.
[탈퇴 전에 확인하세요]
사용자 설정의 개인정보 보호 대시보드에서 다음 작업을 수행할 수 있습니다.
- 플랫폼 내 본인 메시지 삭제
- 떠나기 전 중요한 데이터 내보내기
주의: 계정이 삭제된 이후에는 메시지를 삭제할 수 없습니다. 메시지 삭제를 원하신다면, 계정 삭제가 완료되기 전에 반드시 진행해주세요.
생각이 바뀌었다면, 다시 로그인하시면 삭제 예약이 취소됩니다.
- Fluxer 팀`,
},
inactivityWarning: {
subject: '장기간 미사용으로 Fluxer 계정이 삭제될 예정입니다',
body: `안녕하세요, {username}님.
2년 이상 Fluxer 계정에 로그인하지 않으신 것으로 확인되었습니다.
마지막 로그인: {lastActiveDate, date, full} {lastActiveDate, time, short}
데이터 보존 정책에 따라, 장기간 사용되지 않은 계정은 자동으로 삭제가 예약됩니다. 회원님의 계정은 다음 시점에 영구 삭제됩니다.
삭제 예정 일시: {deletionDate, date, full} {deletionDate, time, short}
[계정을 유지하는 방법]
삭제 예정일 이전에 {loginUrl} 에 로그인만 해주시면, 자동 삭제가 취소됩니다. 추가 조치는 필요하지 않습니다.
[로그인하지 않을 경우]
- 계정과 모든 관련 데이터가 영구적으로 삭제됩니다.
- 메시지는 익명 처리되어 “Deleted User(삭제된 사용자)”로 표시됩니다.
- 이 작업은 되돌릴 수 없습니다.
[메시지를 먼저 삭제하고 싶다면]
계정 삭제 전에 로그인하신 뒤, 사용자 설정의 개인정보 보호 대시보드를 이용해 메시지를 삭제하실 수 있습니다.
다시 Fluxer에서 만나 뵙길 바랍니다!
- Fluxer 팀`,
},
harvestCompleted: {
subject: 'Fluxer 데이터 내보내기가 준비되었습니다',
body: `안녕하세요, {username}님.
요청하신 데이터 내보내기가 완료되었으며, 이제 다운로드하실 수 있습니다!
내보내기 요약:
- 총 메시지 수: {totalMessages, number}
- 파일 크기: {fileSizeMB} MB
- 형식: JSON 파일이 포함된 ZIP 아카이브
데이터 다운로드: {downloadUrl}
중요: 이 다운로드 링크는 {expiresAt, date, full} {expiresAt, time, short} 에 만료됩니다.
내보내기에는 다음 내용이 포함됩니다.
- 채널별로 정리된 모든 메시지
- 채널 메타데이터
- 사용자 프로필 및 계정 정보
- 길드 멤버십 및 설정
- 인증 세션 및 보안 관련 정보
데이터는 분석이 용이하도록 JSON 형식으로 제공됩니다.
데이터 내보내기와 관련해 궁금한 점이 있다면 support@fluxer.app 로 문의해주세요.
- Fluxer 팀`,
},
unbanNotification: {
subject: 'Fluxer 계정 정지가 해제되었습니다',
body: `안녕하세요, {username}님.
좋은 소식입니다! Fluxer 계정에 대한 정지 조치가 해제되었습니다.
사유: {reason}
이제 다시 로그인하여 Fluxer를 이용하실 수 있습니다.
- Fluxer 안전팀`,
},
scheduledDeletionNotification: {
subject: 'Fluxer 계정이 삭제될 예정입니다',
body: `안녕하세요, {username}님.
Fluxer 계정이 영구 삭제될 예정입니다.
삭제 예정 일시: {deletionDate, date, full} {deletionDate, time, short}
사유: {reason}
이는 중대한 조치이며, 예정된 시점에 계정 데이터가 영구적으로 삭제됩니다.
결정이 부당하다고 생각되면, 이 이메일 주소에서 appeals@fluxer.app 로 이의 제기 메일을 보내실 수 있습니다.
- Fluxer 안전팀`,
},
giftChargebackNotification: {
subject: 'Fluxer Premium 선물이 취소되었습니다',
body: `안녕하세요, {username}님.
회원님이 사용하신 Fluxer Premium 선물이, 원 구매자의 결제 분쟁(차지백) 제기로 인해 취소되었습니다.
이에 따라 계정에서 Premium 혜택이 제거되었습니다. 이는 선물 결제가 취소된 데 따른 조치입니다.
궁금한 점이 있으시면 support@fluxer.app 로 문의해주세요.
- Fluxer 팀`,
},
reportResolved: {
subject: 'Fluxer 신고가 검토되었습니다',
body: `안녕하세요, {username}님.
회원님이 제출하신 신고(ID: {reportId})가 Fluxer 안전팀에 의해 검토되었습니다.
안전팀의 답변:
{publicComment}
Fluxer를 모두에게 안전한 공간으로 만드는 데 함께해주셔서 감사합니다.
모든 신고는 중요하게 다루고 있으며, 회원님의 기여에 감사드립니다.
이 결정에 대해 궁금한 점이나 우려 사항이 있다면 safety@fluxer.app 로 문의해주세요.
- Fluxer 안전팀`,
},
dsaReportVerification: {
subject: 'DSA 신고를 위한 이메일 인증',
body: `안녕하세요,
Fluxer에서 디지털 서비스법 신고를 제출하려면 다음 인증 코드를 사용하세요:
{code}
이 코드는 {expiresAt, date, full} {expiresAt, time, short}에 만료됩니다.
요청하지 않으셨다면 이 이메일을 무시하세요.
- Fluxer 안전팀`,
},
registrationApproved: {
subject: 'Fluxer 가입이 승인되었습니다',
body: `안녕하세요, {username}님.
좋은 소식입니다! Fluxer 가입이 승인되었습니다.
이제 아래 링크에서 Fluxer 앱에 로그인하실 수 있습니다.
{channelsUrl}
Fluxer 커뮤니티에 오신 것을 환영합니다!
- Fluxer 팀`,
},
emailChangeRevert: {
subject: 'Fluxer 이메일이 변경되었습니다',
body: `안녕하세요, {username} 님.
Fluxer 계정 이메일이 {newEmail}(으)로 변경되었습니다.
직접 변경하신 경우 추가 조치가 필요 없습니다. 아니라면 아래 링크로 되돌리고 계정을 보호하세요:
{revertUrl}
이렇게 하면 이전 이메일이 복원되고, 모든 세션에서 로그아웃되며, 연결된 전화번호가 제거되고, MFA가 비활성화되며, 새 비밀번호가 필요합니다.
- Fluxer 보안팀`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const lt: EmailTranslations = {
passwordReset: {
subject: 'Atstatykite savo Fluxer slaptažodį',
body: `Sveiki, {username},
Prašėte atstatyti savo Fluxer paskyros slaptažodį. Norėdami nustatyti naują slaptažodį, paspauskite žemiau esančią nuorodą:
{resetUrl}
Jei neprašėte slaptažodžio atstatymo, tiesiog ignoruokite šį laišką.
Nuoroda nustos galioti po 1 valandos.
- Fluxer komanda`,
},
emailVerification: {
subject: 'Patvirtinkite savo Fluxer el. pašto adresą',
body: `Sveiki, {username},
Norėdami patvirtinti savo Fluxer paskyros el. pašto adresą, spustelėkite žemiau esančią nuorodą:
{verifyUrl}
Jei nekūrėte Fluxer paskyros, galite saugiai ignoruoti šį laišką.
Nuoroda nustos galioti po 24 valandų.
- Fluxer komanda`,
},
ipAuthorization: {
subject: 'Patvirtinkite prisijungimą iš naujo IP adreso',
body: `Sveiki, {username},
Nustatėme bandymą prisijungti prie jūsų Fluxer paskyros iš naujo IP adreso:
IP adresas: {ipAddress}
Vieta: {location}
Jei tai buvote jūs, patvirtinkite šį IP adresą spustelėję žemiau esančią nuorodą:
{authUrl}
Jei tai nebuvote jūs, nedelsdami pakeiskite slaptažodį.
Ši patvirtinimo nuoroda nustos galioti po 30 minučių.
- Fluxer komanda`,
},
accountDisabledSuspicious: {
subject: 'Jūsų Fluxer paskyra laikinai išjungta',
body: `Sveiki, {username},
Jūsų Fluxer paskyra buvo laikinai išjungta dėl įtartinos veiklos.
{reason, select,
null {}
other {Priežastis: {reason}
}}Norėdami atgauti prieigą, turite atstatyti slaptažodį:
{forgotUrl}
Atstatę slaptažodį, galėsite vėl prisijungti.
Jei manote, kad klaidingai buvote užblokuoti, susisiekite su mūsų palaikymo komanda.
- Fluxer saugos komanda`,
},
accountTempBanned: {
subject: 'Jūsų Fluxer paskyra laikinai suspenduota',
body: `Sveiki, {username},
Jūsų Fluxer paskyra laikinai suspenduota dėl mūsų paslaugų teikimo taisyklių arba bendruomenės gairių pažeidimo.
Trukmė: {durationHours, plural,
=1 {1 valanda}
other {# valandos}
}
Suspenduota iki: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Priežastis: {reason}}
}
Šiuo laikotarpiu negalėsite naudotis savo paskyra.
Rekomenduojame perskaityti:
- Paslaugų teikimo taisykles: {termsUrl}
- Bendruomenės gaires: {guidelinesUrl}
Jei manote, kad sprendimas neteisingas, galite pateikti apeliaciją el. adresu appeals@fluxer.app. Parašykite, kodėl manote, kad sprendimas klaidingas. Mes peržiūrėsime jūsų apeliaciją ir pateiksime atsakymą.
- Fluxer saugos komanda`,
},
accountScheduledDeletion: {
subject: 'Jūsų Fluxer paskyra numatyta ištrinti',
body: `Sveiki, {username},
Jūsų Fluxer paskyra numatyta nuolatiniam ištrynimui dėl paslaugų teikimo taisyklių arba bendruomenės gairių pažeidimų.
Numatoma ištrynimo data: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Priežastis: {reason}}
}
Tai rimtas sprendimas. Vis jūsų paskyros duomenys bus visam laikui ištrinti nurodytą dieną.
Rekomenduojame perskaityti:
- Paslaugų teikimo taisykles: {termsUrl}
- Bendruomenės gaires: {guidelinesUrl}
APELIACIJŲ PROCESAS:
Jei manote, kad sprendimas neteisingas, turite 30 dienų apeliaciniam prašymui pateikti el. adresu appeals@fluxer.app.
Savo apeliacijoje:
- Aiškiai išdėstykite, kodėl sprendimas neteisingas
- Pateikite papildomų įrodymų ar konteksto
Fluxer saugos komandos narys peržiūrės apeliaciją ir gali sustabdyti ištrynimą iki galutinio sprendimo.
- Fluxer saugos komanda`,
},
selfDeletionScheduled: {
subject: 'Jūsų Fluxer paskyros ištrynimas suplanuotas',
body: `Sveiki, {username},
Gaila matyti jus išeinant! Jūsų Fluxer paskyros ištrynimas suplanuotas.
Numatoma ištrynimo data: {deletionDate, date, full} {deletionDate, time, short}
SVARBU: Galite atšaukti paskyros ištrynimą bet kada iki {deletionDate, date, full} {deletionDate, time, short}, tiesiog prisijungę prie savo paskyros.
PRIEŠ IŠEIDAMI:
Privatumo skydelis naudotojo nustatymuose leidžia:
- Ištrinti savo žinutes platformoje
- Atsisiųsti svarbius duomenis prieš išeinant
Pastaba: Kai paskyra bus ištrinta, žinučių ištrinti nebegalėsite. Jei norite jas ištrinti, padarykite tai iš anksto.
Jei persigalvosite, prisijunkite dar kartą ir atšaukite ištrynimą.
- Fluxer komanda`,
},
inactivityWarning: {
subject: 'Jūsų Fluxer paskyra bus ištrinta dėl neaktyvumo',
body: `Sveiki, {username},
Pastebėjome, kad daugiau nei 2 metus nesijungėte prie savo Fluxer paskyros.
Paskutinis prisijungimas: {lastActiveDate, date, full} {lastActiveDate, time, short}
Pagal mūsų duomenų saugojimo politiką, neaktyvios paskyros yra automatiškai suplanuojamos ištrinimui.
Numatoma ištrynimo data: {deletionDate, date, full} {deletionDate, time, short}
KAIP IŠSAUGOTI PASKYRĄ:
Tiesiog prisijunkite prie savo paskyros {loginUrl} iki nurodytos datos. Nereikia jokių papildomų veiksmų.
JEI NESIJUNGSITE:
- Jūsų paskyra ir visi duomenys bus ištrinti
- Jūsų žinutės bus anonimizuotos („Ištrintas naudotojas“)
- Šio veiksmo anuliuoti nebus galima
NORITE IŠTRINTI SAVO ŽINUTES?
Prisijunkite ir naudokite privatumo skydelį prieš ištrinant paskyrą.
Tikimės, kad dar sugrįšite į Fluxer!
- Fluxer komanda`,
},
harvestCompleted: {
subject: 'Jūsų Fluxer duomenų eksportas paruoštas',
body: `Sveiki, {username},
Jūsų duomenų eksportas baigtas ir jau paruoštas atsisiųsti!
Eksporto suvestinė:
- Žinučių skaičius: {totalMessages, number}
- Failo dydis: {fileSizeMB} MB
- Format: ZIP archyvas su JSON failais
Atsisiųsti duomenis: {downloadUrl}
Svarbu: ši nuoroda nustos galioti {expiresAt, date, full} {expiresAt, time, short}.
Eksporte rasite:
- Visas jūsų žinutes, suskirstytas pagal kanalus
- Kanalų metaduomenis
- Paskyros ir profilio informaciją
- Gildijų narystes ir nustatymus
- Autentifikacijos sesijų duomenis
Duomenys pateikiami JSON formatu, kad būtų lengva analizuoti.
Jei turite klausimų, parašykite support@fluxer.app
- Fluxer komanda`,
},
unbanNotification: {
subject: 'Jūsų Fluxer paskyros suspendavimas panaikintas',
body: `Sveiki, {username},
Geros naujienos! Jūsų Fluxer paskyros suspendavimas panaikintas.
Priežastis: {reason}
Dabar galite vėl prisijungti prie savo paskyros ir naudotis Fluxer.
- Fluxer saugos komanda`,
},
scheduledDeletionNotification: {
subject: 'Jūsų Fluxer paskyra numatyta ištrinti',
body: `Sveiki, {username},
Jūsų Fluxer paskyra numatyta nuolatiniam ištrynimui.
Numatoma ištrynimo data: {deletionDate, date, full} {deletionDate, time, short}
Priežastis: {reason}
Tai rimta priemonė. Jūsų duomenys bus ištrinti nurodytu metu.
Jei manote, kad sprendimas neteisingas, galite pateikti apeliaciją adresu appeals@fluxer.app.
- Fluxer saugos komanda`,
},
giftChargebackNotification: {
subject: 'Jūsų Fluxer Premium dovana buvo atšaukta',
body: `Sveiki, {username},
Informuojame, kad jūsų aktyvuota Fluxer Premium dovana buvo panaikinta dėl mokėjimo ginčo (chargeback), kurį pradėjo pirminis mokėtojas.
Jūsų Premium funkcijos buvo pašalintos iš paskyros.
Jei turite klausimų, rašykite support@fluxer.app
- Fluxer komanda`,
},
reportResolved: {
subject: 'Jūsų Fluxer pranešimas peržiūrėtas',
body: `Sveiki, {username},
Jūsų pranešimas (ID: {reportId}) buvo peržiūrėtas mūsų saugos komandos.
Saugos komandos atsakymas:
{publicComment}
Ačiū, kad padedate išlaikyti Fluxer saugią bendruomenę.
Jei turite klausimų ar nuogąstavimų, rašykite safety@fluxer.app
- Fluxer saugos komanda`,
},
dsaReportVerification: {
subject: 'Patvirtinkite savo el. paštą DSA pranešimui',
body: `Sveiki,
Naudokite šį patvirtinimo kodą pateikti Skaitmeninių paslaugų akto pranešimui Fluxer platformoje:
{code}
Šis kodas nustos galioti {expiresAt, date, full} {expiresAt, time, short}.
Jei neprašėte šio patvirtinimo, tiesiog ignoruokite šį laišką.
- Fluxer saugos komanda`,
},
registrationApproved: {
subject: 'Jūsų registracija Fluxer patvirtinta',
body: `Sveiki, {username},
Puiki žinia! Jūsų registracija Fluxer patvirtinta.
Dabar galite prisijungti prie Fluxer programėlės naudodami šią nuorodą:
{channelsUrl}
Sveiki prisijungę prie Fluxer bendruomenės!
- Fluxer komanda`,
},
emailChangeRevert: {
subject: 'Jūsų Fluxer el. paštas buvo pakeistas',
body: `Sveiki, {username},
Jūsų Fluxer paskyros el. paštas pakeistas į {newEmail}.
Jei pakeitimą atlikote jūs, jokių veiksmų nereikia. Jei ne, galite jį atšaukti ir apsaugoti paskyrą naudodami šią nuorodą:
{revertUrl}
Tai atstatys ankstesnį el. paštą, atjungs jus iš visų sesijų, pašalins susietus telefono numerius, išjungs MFA ir pareikalaus naujo slaptažodžio.
- Fluxer saugumo komanda`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const nl: EmailTranslations = {
passwordReset: {
subject: 'Stel je Fluxer-wachtwoord opnieuw in',
body: `Hallo {username},
Je hebt verzocht om het wachtwoord van je Fluxer-account opnieuw in te stellen. Volg de link hieronder om een nieuw wachtwoord te kiezen:
{resetUrl}
Als je deze aanvraag niet hebt gedaan, kun je deze e-mail veilig negeren.
Deze link verloopt over 1 uur.
- Het Fluxer-team`,
},
emailVerification: {
subject: 'Bevestig je Fluxer e-mailadres',
body: `Hallo {username},
Bevestig het e-mailadres van je Fluxer-account door op de onderstaande link te klikken:
{verifyUrl}
Als je geen Fluxer-account hebt aangemaakt, kun je deze e-mail negeren.
Deze link verloopt over 24 uur.
- Het Fluxer-team`,
},
ipAuthorization: {
subject: 'Autoriseer login vanaf een nieuw IP-adres',
body: `Hallo {username},
We hebben een poging tot inloggen op je Fluxer-account gedetecteerd vanaf een nieuw IP-adres:
IP-adres: {ipAddress}
Locatie: {location}
Als jij dit was, autoriseer dan dit IP-adres via de onderstaande link:
{authUrl}
Als jij dit niet was, wijzig dan onmiddellijk je wachtwoord.
Deze autorisatielink verloopt over 30 minuten.
- Het Fluxer-team`,
},
accountDisabledSuspicious: {
subject: 'Je Fluxer-account is tijdelijk uitgeschakeld',
body: `Hallo {username},
Je Fluxer-account is tijdelijk uitgeschakeld vanwege verdachte activiteiten.
{reason, select,
null {}
other {Reden: {reason}
}}Om opnieuw toegang te krijgen, moet je je wachtwoord opnieuw instellen:
{forgotUrl}
Na het opnieuw instellen van je wachtwoord kun je weer inloggen.
Als je denkt dat dit een fout is, neem dan contact op met ons ondersteuningsteam.
- Het Fluxer Veiligheidsteam`,
},
accountTempBanned: {
subject: 'Je Fluxer-account is tijdelijk geschorst',
body: `Hallo {username},
Je Fluxer-account is tijdelijk geschorst wegens schending van onze Servicevoorwaarden of Gemeenschapsrichtlijnen.
Duur: {durationHours, plural,
=1 {1 uur}
other {# uur}
}
Geschorst tot: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Reden: {reason}}
}
Tijdens deze schorsing heb je geen toegang tot je account.
We raden je aan onze documenten te bekijken:
- Servicevoorwaarden: {termsUrl}
- Gemeenschapsrichtlijnen: {guidelinesUrl}
Als je denkt dat deze beslissing onjuist is, kun je een beroep indienen via appeals@fluxer.app. Leg duidelijk uit waarom de beslissing volgens jou onterecht is. We beoordelen je beroep en laten je onze beslissing weten.
- Het Fluxer Veiligheidsteam`,
},
accountScheduledDeletion: {
subject: 'Je Fluxer-account staat gepland voor verwijdering',
body: `Hallo {username},
Je Fluxer-account staat gepland voor permanente verwijdering wegens overtreding van onze Servicevoorwaarden of Gemeenschapsrichtlijnen.
Geplande verwijderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Reden: {reason}}
}
Dit is een serieuze handhavingsmaatregel. Je accountgegevens worden permanent verwijderd op de geplande datum.
We raden je aan het volgende door te nemen:
- Servicevoorwaarden: {termsUrl}
- Gemeenschapsrichtlijnen: {guidelinesUrl}
BEROEPSPROCEDURE:
Als je denkt dat deze beslissing onjuist of onterecht is, heb je 30 dagen om een beroep in te dienen via appeals@fluxer.app.
Vermeld in je beroep:
- Waarom je denkt dat de beslissing onjuist is
- Eventuele relevante context of bewijs
Een lid van het Fluxer Veiligheidsteam beoordeelt je beroep en kan de verwijdering uitstellen tot een definitieve beslissing is genomen.
- Het Fluxer Veiligheidsteam`,
},
selfDeletionScheduled: {
subject: 'De verwijdering van je Fluxer-account is ingepland',
body: `Hallo {username},
Jammer dat je ons verlaat! De verwijdering van je Fluxer-account is ingepland.
Geplande verwijderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
BELANGRIJK: Je kunt deze verwijdering op elk moment annuleren vóór {deletionDate, date, full} {deletionDate, time, short} door opnieuw in te loggen op je account.
VOORDAT JE GAAT:
Je Privacydashboard in de gebruikersinstellingen laat je:
- Je berichten op het platform verwijderen
- Waardevolle data exporteren voordat je vertrekt
Let op: zodra je account is verwijderd, kun je geen berichten meer verwijderen. Doe dit via het Privacydashboard voordat de verwijdering is voltooid.
Als je van gedachten verandert, log dan gewoon opnieuw in om de verwijdering te annuleren.
- Het Fluxer-team`,
},
inactivityWarning: {
subject: 'Je Fluxer-account wordt verwijderd wegens inactiviteit',
body: `Hallo {username},
We hebben gemerkt dat je al meer dan 2 jaar niet hebt ingelogd op je Fluxer-account.
Laatste login: {lastActiveDate, date, full} {lastActiveDate, time, short}
Volgens ons beleid voor gegevensbewaring worden inactieve accounts automatisch gepland voor verwijdering. Je account wordt permanent verwijderd op:
Geplande verwijderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
HOE JE JE ACCOUNT KUNT BEHOUDEN:
Log gewoon in op {loginUrl} vóór de verwijderingsdatum om automatische verwijdering te annuleren.
ALS JE NIET INLOGT:
- Je account en alle gegevens worden permanent verwijderd
- Je berichten worden geanonimiseerd (“Verwijderde gebruiker”)
- Deze actie kan niet ongedaan worden gemaakt
WIL JE JE BERICHTEN VERWIJDEREN?
Als je je berichten wilt verwijderen voordat je account wordt verwijderd, log dan in en gebruik het Privacydashboard.
We hopen je terug te zien op Fluxer!
- Het Fluxer-team`,
},
harvestCompleted: {
subject: 'Je Fluxer-gegevensexport is klaar',
body: `Hallo {username},
Je gegevensexport is voltooid en staat klaar om te worden gedownload!
Exportoverzicht:
- Totaal aantal berichten: {totalMessages, number}
- Bestandsgrootte: {fileSizeMB} MB
- Formaat: ZIP-archief met JSON-bestanden
Download je gegevens: {downloadUrl}
BELANGRIJK: Deze downloadlink verloopt op {expiresAt, date, full} {expiresAt, time, short}
Je export bevat:
- Al je berichten per kanaal georganiseerd
- Kanaalmetadata
- Je gebruikersprofiel en accountinformatie
- Guild-lidmaatschappen en instellingen
- Authenticatiesessies en beveiligingsinformatie
De data wordt in JSON-formaat aangeleverd voor eenvoudige analyse.
Heb je vragen? Neem contact op via support@fluxer.app
- Het Fluxer-team`,
},
unbanNotification: {
subject: 'Je schorsing voor Fluxer is opgeheven',
body: `Hallo {username},
Goed nieuws! De schorsing van je Fluxer-account is opgeheven.
Reden: {reason}
Je kunt nu opnieuw inloggen en Fluxer blijven gebruiken.
- Het Fluxer Veiligheidsteam`,
},
scheduledDeletionNotification: {
subject: 'Je Fluxer-account staat gepland voor verwijdering',
body: `Hallo {username},
Je Fluxer-account staat gepland voor permanente verwijdering.
Verwijderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
Reden: {reason}
Dit is een serieuze maatregel. Je accountgegevens worden verwijderd op de geplande datum.
Als je denkt dat dit onterecht is, kun je een beroep indienen via appeals@fluxer.app.
- Het Fluxer Veiligheidsteam`,
},
giftChargebackNotification: {
subject: 'Je Fluxer Premium-cadeau is ingetrokken',
body: `Hallo {username},
We informeren je dat het Fluxer Premium-cadeau dat je hebt ingewisseld, is ingetrokken vanwege een betalingsgeschil (chargeback) dat door de oorspronkelijke koper is gestart.
Je premiumvoordelen zijn van je account verwijderd omdat de betaling is teruggedraaid.
Bij vragen kun je contact opnemen via support@fluxer.app.
- Het Fluxer-team`,
},
reportResolved: {
subject: 'Je Fluxer-melding is beoordeeld',
body: `Hallo {username},
Je melding (ID: {reportId}) is beoordeeld door ons Veiligheidsteam.
Reactie van het Veiligheidsteam:
{publicComment}
Bedankt dat je helpt Fluxer veilig te houden voor iedereen. We nemen alle meldingen serieus en waarderen je bijdrage aan onze community.
Als je vragen of zorgen hebt, neem dan contact op via safety@fluxer.app.
- Het Fluxer Veiligheidsteam`,
},
dsaReportVerification: {
subject: 'Verifieer je e-mail voor een DSA-melding',
body: `Hallo,
Gebruik de volgende verificatiecode om je Digital Services Act-melding in te dienen op Fluxer:
{code}
Deze code verloopt op {expiresAt, date, full} {expiresAt, time, short}.
Als je dit niet hebt aangevraagd, kun je deze e-mail negeren.
- Het Fluxer Veiligheidsteam`,
},
registrationApproved: {
subject: 'Je Fluxer-registratie is goedgekeurd',
body: `Hallo {username},
Goed nieuws! Je registratie voor Fluxer is goedgekeurd.
Je kunt nu inloggen in de Fluxer-app via:
{channelsUrl}
Welkom bij de Fluxer-community!
- Het Fluxer-team`,
},
emailChangeRevert: {
subject: 'Je Fluxer-e-mail is gewijzigd',
body: `Hallo {username},
Het e-mailadres van je Fluxer-account is gewijzigd naar {newEmail}.
Als je dit zelf hebt gedaan, hoef je niets te doen. Zo niet, dan kun je het ongedaan maken en je account beveiligen via deze link:
{revertUrl}
Hiermee wordt je vorige e-mail hersteld, word je overal uitgelogd, worden gekoppelde telefoonnummers verwijderd, MFA uitgeschakeld en een nieuw wachtwoord vereist.
- Fluxer-beveiligingsteam`,
},
};

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const no: EmailTranslations = {
passwordReset: {
subject: 'Tilbakestill Fluxer-passordet ditt',
body: `Hei {username},
Du har bedt om å tilbakestille passordet for Fluxer-kontoen din. Følg lenken nedenfor for å opprette et nytt passord:
{resetUrl}
Hvis du ikke ba om dette, kan du trygt ignorere denne e-posten.
Denne lenken utløper om 1 time.
- Fluxer-teamet`,
},
emailVerification: {
subject: 'Bekreft Fluxer-e-postadressen din',
body: `Hei {username},
Bekreft e-postadressen for Fluxer-kontoen din ved å klikke på lenken nedenfor:
{verifyUrl}
Hvis du ikke opprettet en Fluxer-konto, kan du ignorere denne e-posten.
Denne lenken utløper om 24 timer.
- Fluxer-teamet`,
},
ipAuthorization: {
subject: 'Godkjenn innlogging fra ny IP-adresse',
body: `Hei {username},
Vi oppdaget et innloggingsforsøk på Fluxer-kontoen din fra en ny IP-adresse:
IP-adresse: {ipAddress}
Sted: {location}
Hvis dette var deg, vennligst godkjenn IP-adressen ved å klikke på lenken nedenfor:
{authUrl}
Hvis du ikke forsøkte å logge inn, bør du endre passordet ditt umiddelbart.
Denne godkjenningslenken utløper om 30 minutter.
- Fluxer-teamet`,
},
accountDisabledSuspicious: {
subject: 'Fluxer-kontoen din er midlertidig deaktivert',
body: `Hei {username},
Fluxer-kontoen din har blitt midlertidig deaktivert på grunn av mistenkelig aktivitet.
{reason, select,
null {}
other {Årsak: {reason}
}}For å gjenopprette tilgangen må du tilbakestille passordet ditt:
{forgotUrl}
Etter at passordet er tilbakestilt, kan du logge inn igjen.
Hvis du mener dette er en feil, vennligst kontakt brukerstøtten vår.
- Fluxer sikkerhetsteam`,
},
accountTempBanned: {
subject: 'Fluxer-kontoen din er midlertidig utestengt',
body: `Hei {username},
Fluxer-kontoen din har blitt midlertidig utestengt for brudd på våre tjenestevilkår eller retningslinjer for fellesskapet.
Varighet: {durationHours, plural,
=1 {1 time}
other {# timer}
}
Utestengt til: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Årsak: {reason}}
}
I denne perioden vil du ikke ha tilgang til kontoen din.
Vi anbefaler at du leser:
- Tjenestevilkår: {termsUrl}
- Retningslinjer for fellesskapet: {guidelinesUrl}
Hvis du mener at denne avgjørelsen er feil eller urettferdig, kan du sende en klage til appeals@fluxer.app fra denne e-postadressen. Forklar tydelig hvorfor du mener avgjørelsen er feil. Vi vil gjennomgå klagen og gi deg et svar.
- Fluxer sikkerhetsteam`,
},
accountScheduledDeletion: {
subject: 'Fluxer-kontoen din er planlagt for sletting',
body: `Hei {username},
Fluxer-kontoen din er planlagt for permanent sletting på grunn av brudd på våre tjenestevilkår eller retningslinjer for fellesskapet.
Planlagt slettedato: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Årsak: {reason}}
}
Dette er et alvorlig tiltak. Kontoens data vil bli slettet permanent på den planlagte datoen.
Vi anbefaler at du leser:
- Tjenestevilkår: {termsUrl}
- Retningslinjer for fellesskapet: {guidelinesUrl}
KLAGEPROSESS:
Hvis du mener at denne avgjørelsen er feil eller urettferdig, har du 30 dager på deg til å sende en klage til appeals@fluxer.app fra denne e-posten.
Inkluder i klagen:
- En klar forklaring på hvorfor du mener avgjørelsen er feil
- Eventuelle relevante bevis eller kontekst
Et medlem av Fluxers sikkerhetsteam vil gjennomgå klagen og kan utsette slettingen inntil en endelig avgjørelse tas.
- Fluxer sikkerhetsteam`,
},
selfDeletionScheduled: {
subject: 'Sletting av Fluxer-kontoen din er planlagt',
body: `Hei {username},
Vi synes det er trist å se deg gå! Sletting av Fluxer-kontoen din er planlagt.
Planlagt slettedato: {deletionDate, date, full} {deletionDate, time, short}
VIKTIG: Du kan avbryte kontoslettingen når som helst før {deletionDate, date, full} {deletionDate, time, short} ved å logge inn på kontoen din igjen.
FØR DU DRAR:
Personvernpanelet under brukerinnstillinger lar deg:
- Slette meldingene dine på plattformen
- Laste ned viktige data før du drar
Merk: Når kontoen er slettet, kan ikke meldingene slettes lenger. Hvis du ønsker å fjerne meldingene dine, gjør det før kontoen slettes.
Hvis du ombestemmer deg, er det bare å logge inn igjen for å kansellere slettingen.
- Fluxer-teamet`,
},
inactivityWarning: {
subject: 'Fluxer-kontoen din vil bli slettet på grunn av inaktivitet',
body: `Hei {username},
Vi har registrert at du ikke har logget inn på Fluxer-kontoen din på over 2 år.
Siste innlogging: {lastActiveDate, date, full} {lastActiveDate, time, short}
I henhold til våre retningslinjer for datalagring blir inaktive kontoer automatisk planlagt for sletting.
Planlagt slettedato: {deletionDate, date, full} {deletionDate, time, short}
SLIK BEHOLDER DU KONTOEN:
Logg inn på kontoen din via {loginUrl} før slettedatoen. Ingen ytterligere handling kreves.
HVIS DU IKKE LOGGER INN:
- Kontoen din og all tilknyttet data vil bli slettet permanent
- Meldingene dine vil bli anonymisert (“Slettet bruker”)
- Denne handlingen kan ikke angres
VIL DU SLETTE MELDINGENE DINE SELV?
Logg inn og bruk personvernpanelet før kontoen slettes.
Vi håper å se deg tilbake på Fluxer!
- Fluxer-teamet`,
},
harvestCompleted: {
subject: 'Fluxer-dataeksporten din er klar',
body: `Hei {username},
Dataeksporten din er fullført og klar for nedlasting!
Eksportoversikt:
- Totalt antall meldinger: {totalMessages, number}
- Filstørrelse: {fileSizeMB} MB
- Format: ZIP-arkiv med JSON-filer
Last ned dataene dine her: {downloadUrl}
VIKTIG: Denne nedlastingslenken utløper {expiresAt, date, full} {expiresAt, time, short}
Eksporten inkluderer:
- Alle meldingene dine, sortert per kanal
- Kanalmetadata
- Brukerprofil og kontoinformasjon
- Guild-medlemskap og innstillinger
- Autentiseringsøkter og sikkerhetsinformasjon
Data leveres i JSON-format for enkel analyse.
Har du spørsmål? Kontakt support@fluxer.app
- Fluxer-teamet`,
},
unbanNotification: {
subject: 'Utestengelsen av Fluxer-kontoen din er opphevet',
body: `Hei {username},
Gode nyheter! Utestengelsen av Fluxer-kontoen din er opphevet.
Årsak: {reason}
Du kan nå logge inn igjen og fortsette å bruke Fluxer.
- Fluxer sikkerhetsteam`,
},
scheduledDeletionNotification: {
subject: 'Fluxer-kontoen din står planlagt for sletting',
body: `Hei {username},
Fluxer-kontoen din er planlagt for permanent sletting.
Slettedato: {deletionDate, date, full} {deletionDate, time, short}
Årsak: {reason}
Dette er et alvorlig tiltak. Kontoen og dataene dine vil bli slettet på den planlagte datoen.
Hvis du mener at dette er feil, kan du sende en klage til appeals@fluxer.app.
- Fluxer sikkerhetsteam`,
},
giftChargebackNotification: {
subject: 'Fluxer Premium-gaven din er tilbakekalt',
body: `Hei {username},
Vi informerer deg om at Fluxer Premium-gaven du løste inn, er tilbakekalt på grunn av en betalingskonflikt (chargeback) som ble initiert av den opprinnelige kjøperen.
Dine premiumfordeler er fjernet fra kontoen din. Dette ble gjort fordi betalingen ble tilbakeført.
Har du spørsmål? Kontakt support@fluxer.app.
- Fluxer-teamet`,
},
reportResolved: {
subject: 'Fluxer-rapporten din er gjennomgått',
body: `Hei {username},
Rapporten din (ID: {reportId}) er gjennomgått av vårt sikkerhetsteam.
Tilbakemelding fra sikkerhetsteamet:
{publicComment}
Takk for at du bidrar til å holde Fluxer trygt for alle. Vi tar alle rapporter på alvor og setter pris på ditt engasjement for fellesskapet.
Hvis du har spørsmål eller bekymringer, kontakt safety@fluxer.app.
- Fluxer sikkerhetsteam`,
},
dsaReportVerification: {
subject: 'Bekreft e-posten din for en DSA-rapport',
body: `Hei,
Bruk følgende bekreftelseskode for å sende inn din Digital Services Act-rapport på Fluxer:
{code}
Denne koden utløper {expiresAt, date, full} {expiresAt, time, short}.
Hvis du ikke ba om dette, kan du ignorere denne e-posten.
- Fluxer sikkerhetsteam`,
},
registrationApproved: {
subject: 'Fluxer-registreringen din er godkjent',
body: `Hei {username},
God nyhet! Registreringen din hos Fluxer er godkjent.
Du kan nå logge inn i Fluxer-appen via:
{channelsUrl}
Velkommen til Fluxer-fellesskapet!
- Fluxer-teamet`,
},
emailChangeRevert: {
subject: 'E-posten din i Fluxer er endret',
body: `Hei {username},
E-postadressen til Fluxer-kontoen din er endret til {newEmail}.
Hvis du gjorde endringen selv, trenger du ikke gjøre noe mer. Hvis ikke, kan du angre og sikre kontoen via denne lenken:
{revertUrl}
Dette gjenoppretter den forrige e-posten, logger deg ut overalt, fjerner tilknyttede telefonnumre, deaktiverer MFA og krever et nytt passord.
- Fluxer sikkerhetsteam`,
},
};

View File

@@ -0,0 +1,319 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const pl: EmailTranslations = {
passwordReset: {
subject: 'Zresetuj swoje hasło do Fluxer',
body: `Cześć {username},
Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta Fluxer. Kliknij poniższy link, aby ustawić nowe hasło:
{resetUrl}
Jeśli nie prosiłeś(-aś) o reset hasła, możesz bezpiecznie zignorować tę wiadomość.
Ten link wygaśnie za 1 godzinę.
- Zespół Fluxer`,
},
emailVerification: {
subject: 'Zweryfikuj swój adres e-mail w Fluxer',
body: `Cześć {username},
Kliknij poniższy link, aby zweryfikować adres e-mail powiązany z Twoim kontem Fluxer:
{verifyUrl}
Jeśli nie utworzyłeś(-aś) konta Fluxer, możesz zignorować tę wiadomość.
Ten link wygaśnie za 24 godziny.
- Zespół Fluxer`,
},
ipAuthorization: {
subject: 'Autoryzuj logowanie z nowego adresu IP',
body: `Cześć {username},
Wykryliśmy próbę logowania do Twojego konta Fluxer z nowego adresu IP:
Adres IP: {ipAddress}
Lokalizacja: {location}
Jeśli to Ty, kliknij poniższy link, aby autoryzować ten adres IP:
{authUrl}
Jeśli to nie Ty próbowałeś(-aś) się zalogować, zalecamy natychmiastową zmianę hasła.
Ten link autoryzacyjny wygaśnie za 30 minut.
- Zespół Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Twoje konto Fluxer zostało tymczasowo wyłączone',
body: `Cześć {username},
Twoje konto Fluxer zostało tymczasowo wyłączone z powodu podejrzanej aktywności.
{reason, select,
null {}
other {Powód: {reason}
}}Aby odzyskać dostęp do konta, musisz zresetować hasło:
{forgotUrl}
Po zresetowaniu hasła będziesz mógł(-a) ponownie się zalogować.
Jeśli uważasz, że doszło do pomyłki, skontaktuj się z naszym zespołem wsparcia.
- Zespół Bezpieczeństwa Fluxer`,
},
accountTempBanned: {
subject: 'Twoje konto Fluxer zostało tymczasowo zawieszone',
body: `Cześć {username},
Twoje konto Fluxer zostało tymczasowo zawieszone za naruszenie Regulaminu lub Wytycznych Społeczności.
Czas trwania: {durationHours, plural,
=1 {1 godzina}
other {# godzin}
}
Zawieszone do: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Powód: {reason}}
}
W tym czasie nie będziesz mieć dostępu do konta.
Zachęcamy do zapoznania się z:
- Regulaminem: {termsUrl}
- Wytycznymi Społeczności: {guidelinesUrl}
Jeśli uważasz, że decyzja jest błędna lub niesprawiedliwa, możesz wysłać odwołanie na adres appeals@fluxer.app z tego adresu e-mail.
Wyjaśnij dokładnie, dlaczego uważasz, że decyzja była niewłaściwa. Przeanalizujemy Twoje odwołanie i odpowiemy z decyzją.
- Zespół Bezpieczeństwa Fluxer`,
},
accountScheduledDeletion: {
subject: 'Twoje konto Fluxer jest zaplanowane do usunięcia',
body: `Cześć {username},
Twoje konto Fluxer zostało zakwalifikowane do trwałego usunięcia z powodu naruszenia Regulaminu lub Wytycznych Społeczności.
Planowana data usunięcia: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Powód: {reason}}
}
To poważne działanie. Wszystkie dane konta zostaną trwale usunięte w określonym terminie.
Zachęcamy do zapoznania się z:
- Regulaminem: {termsUrl}
- Wytycznymi Społeczności: {guidelinesUrl}
PROCES ODWOŁANIA:
Jeśli uważasz, że decyzja jest błędna lub niesprawiedliwa, masz 30 dni na złożenie odwołania na adres appeals@fluxer.app z tego adresu e-mail.
W odwołaniu:
- Wyjaśnij, dlaczego decyzja Twoim zdaniem jest błędna
- Podaj wszelkie istotne dowody lub kontekst
Członek zespołu bezpieczeństwa Fluxer przeanalizuje odwołanie i może wstrzymać usunięcie do czasu wydania ostatecznej decyzji.
- Zespół Bezpieczeństwa Fluxer`,
},
selfDeletionScheduled: {
subject: 'Usunięcie Twojego konta Fluxer zostało zaplanowane',
body: `Cześć {username},
Przykro nam, że odchodzisz! Usunięcie Twojego konta Fluxer zostało zaplanowane.
Planowana data usunięcia: {deletionDate, date, full} {deletionDate, time, short}
WAŻNE: Możesz anulować usunięcie konta w dowolnym momencie przed {deletionDate, date, full} {deletionDate, time, short}, logując się ponownie.
ZANIM ODEJDZIESZ:
Panel Prywatności w ustawieniach konta pozwala na:
- Usuwanie swoich wiadomości na platformie
- Eksportowanie ważnych danych przed odejściem
Uwaga: Po usunięciu konta nie będzie można usunąć wiadomości. Jeśli chcesz je usunąć, zrób to przed finalizacją usunięcia konta.
Jeśli zmienisz zdanie, po prostu zaloguj się ponownie, aby anulować usunięcie.
- Zespół Fluxer`,
},
inactivityWarning: {
subject: 'Twoje konto Fluxer zostanie usunięte z powodu nieaktywności',
body: `Cześć {username},
Zauważyliśmy, że nie logowałeś(-aś) się na swoje konto Fluxer od ponad 2 lat.
Ostatnie logowanie: {lastActiveDate, date, full} {lastActiveDate, time, short}
Zgodnie z naszą polityką przechowywania danych, nieaktywne konta są automatycznie kwalifikowane do usunięcia.
Planowana data usunięcia: {deletionDate, date, full} {deletionDate, time, short}
JAK ZACHOWAĆ KONTO:
Wystarczy zalogować się na {loginUrl} przed datą usunięcia. Nie trzeba wykonywać żadnych dodatkowych czynności.
JEŚLI SIĘ NIE ZALOGUJESZ:
- Twoje konto oraz wszystkie powiązane dane zostaną trwale usunięte
- Twoje wiadomości zostaną zanonimizowane („Usunięty użytkownik”)
- Tego działania nie można cofnąć
CHCESZ USUNĄĆ SWOJE WIADOMOŚCI?
Zaloguj się i użyj Panelu Prywatności przed usunięciem konta.
Mamy nadzieję, że jeszcze wrócisz na Fluxer!
- Zespół Fluxer`,
},
harvestCompleted: {
subject: 'Twój eksport danych Fluxer jest gotowy',
body: `Cześć {username},
Eksport Twoich danych został ukończony i jest gotowy do pobrania!
Podsumowanie eksportu:
- Liczba wiadomości: {totalMessages, number}
- Rozmiar pliku: {fileSizeMB} MB
- Format: archiwum ZIP z plikami JSON
Pobierz swoje dane: {downloadUrl}
WAŻNE: Ten link wygaśnie dnia {expiresAt, date, full} o {expiresAt, time, short}
Eksport zawiera:
- Wszystkie Twoje wiadomości posortowane według kanałów
- Metadane kanałów
- Informacje o profilu i koncie
- Przynależność do gildii oraz ustawienia
- Sesje uwierzytelniające i dane bezpieczeństwa
Dane są zapisane w formacie JSON, aby ułatwić analizę.
W razie pytań napisz na support@fluxer.app
- Zespół Fluxer`,
},
unbanNotification: {
subject: 'Zawieszenie Twojego konta Fluxer zostało zniesione',
body: `Cześć {username},
Dobre wieści! Zawieszenie Twojego konta Fluxer zostało zniesione.
Powód: {reason}
Możesz ponownie zalogować się i korzystać z Fluxer.
- Zespół Bezpieczeństwa Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Twoje konto Fluxer jest zaplanowane do usunięcia',
body: `Cześć {username},
Twoje konto Fluxer zostało zakwalifikowane do trwałego usunięcia.
Data usunięcia: {deletionDate, date, full} {deletionDate, time, short}
Powód: {reason}
Jest to poważne działanie. Twoje dane zostaną usunięte w podanym terminie.
Jeśli uważasz, że decyzja jest błędna, możesz wysłać odwołanie na appeals@fluxer.app
- Zespół Bezpieczeństwa Fluxer`,
},
giftChargebackNotification: {
subject: 'Twój prezent Fluxer Premium został cofnięty',
body: `Cześć {username},
Informujemy, że Twój prezent Fluxer Premium został cofnięty z powodu sporu dotyczącego płatności (chargeback), zgłoszonego przez pierwotnego nabywcę.
Korzyści premium zostały usunięte z Twojego konta, ponieważ płatność została cofnięta.
W razie pytań napisz na support@fluxer.app
- Zespół Fluxer`,
},
reportResolved: {
subject: 'Twoje zgłoszenie do Fluxer zostało rozpatrzone',
body: `Cześć {username},
Twoje zgłoszenie (ID: {reportId}) zostało przeanalizowane przez nasz Zespół Bezpieczeństwa.
Odpowiedź Zespołu Bezpieczeństwa:
{publicComment}
Dziękujemy za pomoc w utrzymaniu bezpieczeństwa na Fluxer. Doceniamy Twój wkład w rozwój naszej społeczności.
W razie pytań lub wątpliwości skontaktuj się: safety@fluxer.app
- Zespół Bezpieczeństwa Fluxer`,
},
dsaReportVerification: {
subject: 'Zweryfikuj swój e-mail dla zgłoszenia DSA',
body: `Witaj,
Użyj następującego kodu weryfikacyjnego, aby przesłać zgłoszenie zgodnie z ustawą o usługach cyfrowych (Digital Services Act) na Fluxer:
{code}
Ten kod wygasa {expiresAt, date, full} o {expiresAt, time, short}.
Jeśli tego nie prosiłeś(-aś), zignoruj ten e-mail.
- Zespół Bezpieczeństwa Fluxer`,
},
registrationApproved: {
subject: 'Twoja rejestracja w Fluxer została zatwierdzona',
body: `Cześć {username},
Świetne wieści! Twoja rejestracja w Fluxer została zatwierdzona.
Możesz teraz zalogować się do aplikacji Fluxer tutaj:
{channelsUrl}
Witamy w społeczności Fluxer!
- Zespół Fluxer`,
},
emailChangeRevert: {
subject: 'Twój e-mail Fluxer został zmieniony',
body: `Cześć {username},
E-mail Twojego konta Fluxer zmieniono na {newEmail}.
Jeśli to Ty wprowadziłeś tę zmianę, nic nie musisz robić. Jeśli nie, możesz ją cofnąć i zabezpieczyć konto tym linkiem:
{revertUrl}
To przywróci poprzedni e-mail, wyloguje Cię z każdego miejsca, usunie powiązane numery telefonów, wyłączy MFA i wymusi ustawienie nowego hasła.
- Zespół Bezpieczeństwa Fluxer`,
},
};

View File

@@ -0,0 +1,319 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const ptBR: EmailTranslations = {
passwordReset: {
subject: 'Redefina sua senha do Fluxer',
body: `Olá {username},
Você solicitou a redefinição da senha da sua conta Fluxer. Clique no link abaixo para definir uma nova senha:
{resetUrl}
Se você não solicitou essa alteração, pode ignorar este e-mail com segurança.
Este link expira em 1 hora.
- Equipe Fluxer`,
},
emailVerification: {
subject: 'Verifique seu endereço de e-mail do Fluxer',
body: `Olá {username},
Por favor, verifique o endereço de e-mail da sua conta Fluxer clicando no link abaixo:
{verifyUrl}
Se você não criou uma conta Fluxer, basta ignorar este e-mail.
Este link expira em 24 horas.
- Equipe Fluxer`,
},
ipAuthorization: {
subject: 'Autorize login de um novo endereço IP',
body: `Olá {username},
Detectamos uma tentativa de login na sua conta Fluxer a partir de um novo endereço IP:
Endereço IP: {ipAddress}
Localização: {location}
Se foi você, autorize o acesso clicando no link abaixo:
{authUrl}
Se você não tentou fazer login, recomendamos que altere sua senha imediatamente.
Este link de autorização expira em 30 minutos.
- Equipe Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Sua conta Fluxer foi temporariamente desativada',
body: `Olá {username},
Sua conta Fluxer foi temporariamente desativada devido a atividade suspeita.
{reason, select,
null {}
other {Motivo: {reason}
}}Para recuperar o acesso à sua conta, você deve redefinir sua senha:
{forgotUrl}
Após redefinir sua senha, você poderá fazer login novamente.
Se acredita que isso foi um engano, entre em contato com nossa equipe de suporte.
- Equipe de Segurança Fluxer`,
},
accountTempBanned: {
subject: 'Sua conta Fluxer foi temporariamente suspensa',
body: `Olá {username},
Sua conta Fluxer foi temporariamente suspensa por violar nossos Termos de Serviço ou Diretrizes da Comunidade.
Duração: {durationHours, plural,
=1 {1 hora}
other {# horas}
}
Suspensa até: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Durante a suspensão, você não poderá acessar sua conta.
Recomendamos revisar:
- Termos de Serviço: {termsUrl}
- Diretrizes da Comunidade: {guidelinesUrl}
Se acredita que essa decisão foi incorreta ou injusta, envie um recurso para appeals@fluxer.app usando este endereço de e-mail.
Explique claramente por que acredita que a decisão está errada. Avaliaremos seu recurso e responderemos com nossa decisão.
- Equipe de Segurança Fluxer`,
},
accountScheduledDeletion: {
subject: 'Sua conta Fluxer está programada para exclusão',
body: `Olá {username},
Sua conta Fluxer foi programada para exclusão permanente devido a violações dos Termos de Serviço ou Diretrizes da Comunidade.
Data programada para exclusão: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Motivo: {reason}}
}
Esta é uma ação séria. Seus dados serão excluídos permanentemente na data programada.
Recomendamos revisar:
- Termos de Serviço: {termsUrl}
- Diretrizes da Comunidade: {guidelinesUrl}
PROCESSO DE RECURSO:
Se acredita que esta decisão foi incorreta ou injusta, você tem 30 dias para enviar um recurso para appeals@fluxer.app através deste e-mail.
No seu recurso:
- Explique por que acredita que a decisão foi equivocada
- Forneça qualquer evidência ou contexto relevante
Um membro da Equipe de Segurança Fluxer analisará o recurso e poderá suspender a exclusão até uma decisão final.
- Equipe de Segurança Fluxer`,
},
selfDeletionScheduled: {
subject: 'A exclusão da sua conta Fluxer foi agendada',
body: `Olá {username},
Sentimos muito em ver você partir! A exclusão da sua conta Fluxer foi agendada.
Data programada para exclusão: {deletionDate, date, full} {deletionDate, time, short}
IMPORTANTE: Você pode cancelar esta exclusão a qualquer momento antes de {deletionDate, date, full} {deletionDate, time, short}, simplesmente fazendo login novamente.
ANTES DE SAIR:
O Painel de Privacidade nas Configurações permite que você:
- Exclua suas mensagens da plataforma
- Exporte dados importantes antes de sair
Atenção: Após a exclusão da conta, não será mais possível excluir mensagens. Caso deseje excluir suas mensagens, faça isso antes da exclusão final.
Se mudar de ideia, basta fazer login novamente.
- Equipe Fluxer`,
},
inactivityWarning: {
subject: 'Sua conta Fluxer será excluída por inatividade',
body: `Olá {username},
Notamos que você não acessa sua conta Fluxer há mais de 2 anos.
Último acesso: {lastActiveDate, date, full} {lastActiveDate, time, short}
De acordo com nossa política de retenção de dados, contas inativas são automaticamente programadas para exclusão.
Data programada para exclusão: {deletionDate, date, full} {deletionDate, time, short}
COMO MANTER SUA CONTA:
Basta fazer login em {loginUrl} antes da data de exclusão. Não é necessário realizar mais nenhuma ação.
SE VOCÊ NÃO FIZER LOGIN:
- Sua conta e todos os dados serão permanentemente excluídos
- Suas mensagens serão anonimizadas (“Usuário excluído”)
- Esta ação não pode ser desfeita
QUER EXCLUIR SUAS MENSAGENS?
Faça login e utilize o Painel de Privacidade antes da exclusão.
Esperamos vê-lo novamente no Fluxer!
- Equipe Fluxer`,
},
harvestCompleted: {
subject: 'Sua exportação de dados Fluxer está pronta',
body: `Olá {username},
Sua exportação de dados foi concluída e está pronta para download!
Resumo da exportação:
- Total de mensagens: {totalMessages, number}
- Tamanho do arquivo: {fileSizeMB} MB
- Formato: Arquivo ZIP contendo arquivos JSON
Baixe seus dados: {downloadUrl}
IMPORTANTE: Este link expirará em {expiresAt, date, full} {expiresAt, time, short}
A exportação inclui:
- Todas as suas mensagens organizadas por canal
- Metadados dos canais
- Informações do seu perfil e conta
- Assinaturas de guilda e configurações
- Sessões de autenticação e dados de segurança
Os dados são fornecidos em formato JSON, facilitando a análise.
Dúvidas? Entre em contato via support@fluxer.app
- Equipe Fluxer`,
},
unbanNotification: {
subject: 'A suspensão da sua conta Fluxer foi removida',
body: `Olá {username},
Boas notícias! A suspensão da sua conta Fluxer foi removida.
Motivo: {reason}
Agora você pode acessar sua conta novamente e continuar usando o Fluxer.
- Equipe de Segurança Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Sua conta Fluxer está programada para exclusão',
body: `Olá {username},
Sua conta Fluxer foi agendada para exclusão permanente.
Data de exclusão: {deletionDate, date, full} {deletionDate, time, short}
Motivo: {reason}
Esta é uma ação séria e seus dados serão excluídos permanentemente.
Se acredita que esta decisão está incorreta, envie um recurso para appeals@fluxer.app.
- Equipe de Segurança Fluxer`,
},
giftChargebackNotification: {
subject: 'Seu presente Fluxer Premium foi revogado',
body: `Olá {username},
Informamos que o presente Fluxer Premium que você resgatou foi revogado devido a uma disputa de pagamento (chargeback) realizada pelo comprador original.
Seus benefícios premium foram removidos da conta, pois o pagamento foi revertido.
Se tiver dúvidas, entre em contato via support@fluxer.app
- Equipe Fluxer`,
},
reportResolved: {
subject: 'Sua denúncia no Fluxer foi analisada',
body: `Olá {username},
Sua denúncia (ID: {reportId}) foi analisada pela nossa Equipe de Segurança.
Resposta da Equipe de Segurança:
{publicComment}
Obrigado por ajudar a manter o Fluxer seguro para todos. Agradecemos sua contribuição para a comunidade.
Se tiver dúvidas ou preocupações, entre em contato via safety@fluxer.app.
- Equipe de Segurança Fluxer`,
},
dsaReportVerification: {
subject: 'Verifique seu e-mail para uma denúncia DSA',
body: `Olá,
Use o seguinte código de verificação para enviar sua denúncia da Lei de Serviços Digitais no Fluxer:
{code}
Este código expira em {expiresAt, date, full} {expiresAt, time, short}.
Se você não solicitou isso, por favor ignore este e-mail.
- Equipe de Segurança Fluxer`,
},
registrationApproved: {
subject: 'Seu cadastro no Fluxer foi aprovado',
body: `Olá {username},
Boas notícias! Seu cadastro no Fluxer foi aprovado.
Você já pode acessar o aplicativo do Fluxer:
{channelsUrl}
Bem-vindo à comunidade Fluxer!
- Equipe Fluxer`,
},
emailChangeRevert: {
subject: 'Seu e-mail da Fluxer foi alterado',
body: `Olá {username},
O e-mail da sua conta Fluxer foi alterado para {newEmail}.
Se você fez essa alteração, nenhuma ação é necessária. Caso contrário, você pode reverter e proteger sua conta usando este link:
{revertUrl}
Isso restaurará seu e-mail anterior, encerrará suas sessões em todos os dispositivos, removerá telefones vinculados, desativará o MFA e exigirá uma nova senha.
- Equipe de Segurança da Fluxer`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const ro: EmailTranslations = {
passwordReset: {
subject: 'Resetează-ți parola Fluxer',
body: `Salut {username},
Ai solicitat resetarea parolei pentru contul tău Fluxer. Te rugăm să urmezi linkul de mai jos pentru a seta o parolă nouă:
{resetUrl}
Dacă nu ai cerut această resetare, poți ignora acest email în siguranță.
Acest link va expira în 1 oră.
- Echipa Fluxer`,
},
emailVerification: {
subject: 'Verifică-ți adresa de email Fluxer',
body: `Salut {username},
Te rugăm să îți verifici adresa de email pentru contul Fluxer accesând linkul de mai jos:
{verifyUrl}
Dacă nu ai creat un cont Fluxer, poți ignora acest email.
Acest link va expira în 24 de ore.
- Echipa Fluxer`,
},
ipAuthorization: {
subject: 'Autorizează conectarea de pe o adresă IP nouă',
body: `Salut {username},
Am detectat o încercare de conectare la contul tău Fluxer de pe o adresă IP nouă:
Adresă IP: {ipAddress}
Locație: {location}
Dacă tu ai fost, te rugăm să autorizezi această adresă IP accesând linkul de mai jos:
{authUrl}
Dacă nu ai încercat să te conectezi, schimbă imediat parola contului tău.
Acest link de autorizare va expira în 30 de minute.
- Echipa Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Contul tău Fluxer a fost dezactivat temporar',
body: `Salut {username},
Contul tău Fluxer a fost dezactivat temporar din cauza activității suspecte.
{reason, select,
null {}
other {Motiv: {reason}
}}Pentru a recăpăta accesul, trebuie să îți resetezi parola:
{forgotUrl}
După resetarea parolei, vei putea să te conectezi din nou.
Dacă crezi că aceasta este o greșeală, te rugăm să contactezi echipa noastră de suport.
- Echipa de Securitate Fluxer`,
},
accountTempBanned: {
subject: 'Contul tău Fluxer a fost suspendat temporar',
body: `Salut {username},
Contul tău Fluxer a fost suspendat temporar pentru încălcarea Termenilor de Utilizare sau a Ghidurilor Comunității.
Durată: {durationHours, plural,
=1 {1 oră}
other {# ore}
}
Suspendat până la: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {Motiv: {reason}}
}
Pe durata suspendării nu vei avea acces la contul tău.
Îți recomandăm să consulți:
- Termenii de Utilizare: {termsUrl}
- Ghidurile Comunității: {guidelinesUrl}
Dacă consideri că această decizie este greșită sau nedreaptă, poți trimite o contestație la appeals@fluxer.app de pe această adresă de email.
Te rugăm să explici clar de ce consideri că decizia este incorectă. Vom analiza contestația ta și îți vom comunica rezultatul.
- Echipa de Securitate Fluxer`,
},
accountScheduledDeletion: {
subject: 'Contul tău Fluxer este programat pentru ștergere',
body: `Salut {username},
Contul tău Fluxer a fost programat pentru ștergere permanentă din cauza încălcării Termenilor de Utilizare sau a Ghidurilor Comunității.
Data programată pentru ștergere: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {Motiv: {reason}}
}
Aceasta este o măsură serioasă. Toate datele contului vor fi șterse definitiv la data programată.
Îți recomandăm să consulți:
- Termenii de Utilizare: {termsUrl}
- Ghidurile Comunității: {guidelinesUrl}
PROCES DE CONTESTAȚIE:
Dacă consideri că această decizie este greșită sau nedreaptă, ai la dispoziție 30 de zile pentru a trimite o contestație la appeals@fluxer.app de pe această adresă de email.
În contestație:
- Explică clar de ce consideri decizia incorectă sau nedreaptă
- Oferă orice dovezi sau context suplimentar relevant
Un membru al Echipei de Securitate Fluxer va analiza contestația și poate suspenda ștergerea până la o decizie finală.
- Echipa de Securitate Fluxer`,
},
selfDeletionScheduled: {
subject: 'Ștergerea contului tău Fluxer a fost programată',
body: `Salut {username},
Ne pare rău să te vedem plecând! Ștergerea contului tău Fluxer a fost programată.
Data programată pentru ștergere: {deletionDate, date, full} {deletionDate, time, short}
IMPORTANT: Poți anula această ștergere în orice moment înainte de {deletionDate, date, full} {deletionDate, time, short} prin simpla reconectare la cont.
ÎNAINTE DE A PLECA:
Panoul de Confidențialitate din Setările Utilizatorului îți permite să:
- Ștergi mesajele tale din platformă
- Exporezi date utile înainte de plecare
Notă: După ștergerea contului, nu vei mai putea șterge mesajele. Dacă dorești să le ștergi, te rugăm să faci acest lucru înainte de finalizarea ștergerii contului.
Dacă te răzgândești, reconectează-te pentru a anula ștergerea.
- Echipa Fluxer`,
},
inactivityWarning: {
subject: 'Contul tău Fluxer va fi șters din cauza inactivității',
body: `Salut {username},
Am observat că nu te-ai conectat la contul tău Fluxer de peste 2 ani.
Ultima conectare: {lastActiveDate, date, full} {lastActiveDate, time, short}
Conform politicii noastre de păstrare a datelor, conturile inactive sunt programate automat pentru ștergere.
Data programată pentru ștergere: {deletionDate, date, full} {deletionDate, time, short}
CUM SĂ ÎȚI PĂSTREZI CONTUL:
Trebuie doar să te conectezi la contul tău la {loginUrl} înainte de data ștergerii. Nu este necesară nicio altă acțiune.
DACĂ NU TE CONECTEZI:
- Contul și toate datele tale vor fi șterse definitiv
- Mesajele tale vor fi anonimizate („Utilizator Șters”)
- Această acțiune nu poate fi anulată
VREI SĂ ȘTERGI MESAJELE TALE?
Te rugăm să te conectezi și să folosești Panoul de Confidențialitate înainte de ștergerea contului.
Sperăm să te revedem pe Fluxer!
- Echipa Fluxer`,
},
harvestCompleted: {
subject: 'Exportul tău de date Fluxer este gata',
body: `Salut {username},
Exportul datelor tale Fluxer a fost finalizat și este gata pentru descărcare!
Rezumatul exportului:
- Număr total de mesaje: {totalMessages, number}
- Dimensiunea fișierului: {fileSizeMB} MB
- Format: Arhivă ZIP cu fișiere JSON
Descarcă datele tale aici: {downloadUrl}
IMPORTANT: Acest link de descărcare va expira la {expiresAt, date, full} {expiresAt, time, short}
Exportul include:
- Toate mesajele tale organizate pe canale
- Metadate ale canalelor
- Profilul utilizatorului și informațiile contului
- Apartenența la guild-uri și setările
- Sesiuni de autentificare și informații de securitate
Datele sunt livrate în format JSON pentru a facilita analiza.
Dacă ai întrebări, ne poți contacta la support@fluxer.app
- Echipa Fluxer`,
},
unbanNotification: {
subject: 'Suspendarea contului tău Fluxer a fost ridicată',
body: `Salut {username},
Vești bune! Suspendarea contului tău Fluxer a fost ridicată.
Motiv: {reason}
Acum poți să te conectezi din nou și să folosești Fluxer.
- Echipa de Securitate Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Contul tău Fluxer este programat pentru ștergere',
body: `Salut {username},
Contul tău Fluxer a fost programat pentru ștergere permanentă.
Data ștergerii: {deletionDate, date, full} {deletionDate, time, short}
Motiv: {reason}
Aceasta este o acțiune serioasă. Toate datele tale vor fi șterse la data programată.
Dacă consideri că decizia este incorectă, poți trimite o contestație la appeals@fluxer.app
- Echipa de Securitate Fluxer`,
},
giftChargebackNotification: {
subject: 'Cadoul tău Fluxer Premium a fost revocat',
body: `Salut {username},
Îți aducem la cunoștință că darul tău Fluxer Premium a fost revocat din cauza unui litigiu de plată (chargeback) inițiat de cumpărătorul original.
Beneficiile premium au fost eliminate din contul tău.
Dacă ai întrebări, ne poți contacta la support@fluxer.app
- Echipa Fluxer`,
},
reportResolved: {
subject: 'Raportul tău către Fluxer a fost analizat',
body: `Salut {username},
Raportul tău (ID: {reportId}) a fost analizat de către Echipa de Securitate Fluxer.
Răspunsul Echipei de Securitate:
{publicComment}
Îți mulțumim că ajuți la menținerea siguranței pe Fluxer. Apreciem contribuția ta la comunitate.
Dacă ai nelămuriri sau întrebări, ne poți contacta la safety@fluxer.app
- Echipa de Securitate Fluxer`,
},
dsaReportVerification: {
subject: 'Verifică-ți e-mailul pentru un raport DSA',
body: `Salut,
Folosește următorul cod de verificare pentru a trimite raportul tău conform Legii serviciilor digitale pe Fluxer:
{code}
Acest cod expiră la {expiresAt, date, full} {expiresAt, time, short}.
Dacă nu ai solicitat aceasta, te rugăm să ignori acest e-mail.
- Echipa de Securitate Fluxer`,
},
registrationApproved: {
subject: 'Înregistrarea ta pe Fluxer a fost aprobată',
body: `Salut {username},
Felicitări! Înregistrarea ta pe Fluxer a fost aprobată.
Poți acum să te conectezi la aplicația Fluxer aici:
{channelsUrl}
Bine ai venit în comunitatea Fluxer!
- Echipa Fluxer`,
},
emailChangeRevert: {
subject: 'E-mailul tău Fluxer a fost modificat',
body: `Bună {username},
E-mailul contului tău Fluxer a fost schimbat în {newEmail}.
Dacă tu ai făcut schimbarea, nu este nevoie de alte acțiuni. Dacă nu, o poți anula și îți poți securiza contul cu acest link:
{revertUrl}
Aceasta va restaura e-mailul anterior, te va deconecta de peste tot, va elimina numerele de telefon asociate, va dezactiva MFA și va necesita o parolă nouă.
- Echipa de Securitate Fluxer`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const ru: EmailTranslations = {
passwordReset: {
subject: 'Сброс вашего пароля Fluxer',
body: `Здравствуйте, {username},
Вы запросили сброс пароля для вашей учетной записи Fluxer. Перейдите по ссылке ниже, чтобы установить новый пароль:
{resetUrl}
Если вы не запрашивали смену пароля, просто проигнорируйте это письмо.
Ссылка действительна 1 час.
- Команда Fluxer`,
},
emailVerification: {
subject: 'Подтверждение вашего email для Fluxer',
body: `Здравствуйте, {username},
Пожалуйста, подтвердите адрес электронной почты вашей учетной записи Fluxer, перейдя по ссылке ниже:
{verifyUrl}
Если вы не создавали учетную запись Fluxer, просто проигнорируйте это письмо.
Ссылка действительна 24 часа.
- Команда Fluxer`,
},
ipAuthorization: {
subject: 'Подтверждение входа с нового IP-адреса',
body: `Здравствуйте, {username},
Мы обнаружили попытку входа в вашу учетную запись Fluxer с нового IP-адреса:
IP-адрес: {ipAddress}
Местоположение: {location}
Если это были вы, подтвердите вход по ссылке ниже:
{authUrl}
Если это были не вы, немедленно смените пароль.
Ссылка для подтверждения действует 30 минут.
- Команда Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Ваша учетная запись Fluxer временно отключена',
body: `Здравствуйте, {username},
Ваша учетная запись Fluxer была временно отключена из-за подозрительной активности.
{reason, select,
null {}
other {Причина: {reason}
}}Чтобы восстановить доступ к учетной записи, вам необходимо сбросить пароль:
{forgotUrl}
После сброса пароля вы сможете снова войти.
Если вы считаете, что произошла ошибка, свяжитесь с нашей службой поддержки.
- Команда безопасности Fluxer`,
},
accountTempBanned: {
subject: 'Ваша учетная запись Fluxer временно заблокирована',
body: `Здравствуйте, {username},
Ваша учетная запись Fluxer временно заблокирована за нарушение наших Условий обслуживания или Правил сообщества.
Длительность блокировки: {durationHours, plural,
=1 {1 час}
other {# часов}
}
Блокировка действует до: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {Причина: {reason}}
}
Во время блокировки вы не сможете получить доступ к своему аккаунту.
Пожалуйста, ознакомьтесь с:
- Условиями обслуживания: {termsUrl}
- Правилами сообщества: {guidelinesUrl}
Если вы считаете, что блокировка была ошибочной, вы можете подать апелляцию, написав на appeals@fluxer.app с этого адреса электронной почты.
Опишите подробно, почему вы считаете, что решение неверно. Мы рассмотрим апелляцию и ответим вам.
- Команда безопасности Fluxer`,
},
accountScheduledDeletion: {
subject: 'Ваша учетная запись Fluxer назначена к удалению',
body: `Здравствуйте, {username},
Ваша учетная запись Fluxer назначена для полного удаления в связи с нарушением наших Условий обслуживания или Правил сообщества.
Дата удаления: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {Причина: {reason}}
}
Это серьезная мера. Все данные учетной записи будут окончательно удалены в указанную дату.
Пожалуйста, ознакомьтесь:
- Условия обслуживания: {termsUrl}
- Правила сообщества: {guidelinesUrl}
ПРОЦЕСС ПОДАЧИ АПЕЛЛЯЦИИ:
Если вы считаете, что решение ошибочно, у вас есть 30 дней, чтобы отправить апелляцию на appeals@fluxer.app.
Укажите:
- Почему вы считаете решение неверным
- Любые доказательства или контекст
Команда безопасности Fluxer рассмотрит апелляцию и может отложить удаление до принятия окончательного решения.
- Команда безопасности Fluxer`,
},
selfDeletionScheduled: {
subject: 'Удаление вашей учетной записи Fluxer запланировано',
body: `Здравствуйте, {username},
Нам жаль, что вы уходите! Удаление вашей учетной записи Fluxer запланировано.
Запланированная дата удаления: {deletionDate, date, full} {deletionDate, time, short}
ВАЖНО: Вы можете отменить удаление в любое время до {deletionDate, date, full} {deletionDate, time, short}, просто снова войдя в свой аккаунт.
ПЕРЕД УХОДОМ:
Панель конфиденциальности в настройках пользователя позволяет вам:
- Удалить ваши сообщения на платформе
- Экспортировать важные данные перед удалением
Обратите внимание: После удаления учетной записи ваши сообщения удалить будет невозможно. Если хотите их удалить, сделайте это заранее.
Если вы передумаете, просто войдите снова.
- Команда Fluxer`,
},
inactivityWarning: {
subject: 'Ваша учетная запись Fluxer будет удалена из-за неактивности',
body: `Здравствуйте, {username},
Мы заметили, что вы не входили в свою учетную запись Fluxer более 2 лет.
Последний вход: {lastActiveDate, date, full} {lastActiveDate, time, short}
Согласно нашей политике хранения данных, неактивные учетные записи автоматически назначаются к удалению.
Запланированная дата удаления: {deletionDate, date, full} {deletionDate, time, short}
КАК СОХРАНИТЬ ВАШ АККАУНТ:
Просто войдите в аккаунт на {loginUrl} до указанной даты. Это отменит автоматическое удаление.
ЕСЛИ ВЫ НЕ ВОЙДЕТЕ:
- Ваша учетная запись и все связанные данные будут навсегда удалены
- Ваши сообщения будут анонимизированы («Удалённый пользователь»)
- Это действие необратимо
ХОТИТЕ УДАЛИТЬ СООБЩЕНИЯ САМОСТОЯТЕЛЬНО?
Войдите и используйте Панель конфиденциальности до удаления аккаунта.
Мы надеемся, что вы вернетесь к Fluxer!
- Команда Fluxer`,
},
harvestCompleted: {
subject: 'Ваш экспорт данных Fluxer готов',
body: `Здравствуйте, {username},
Ваш экспорт данных успешно завершён и готов к загрузке!
Краткая сводка:
- Общее количество сообщений: {totalMessages, number}
- Размер файла: {fileSizeMB} MB
- Формат: ZIP-архив с файлами JSON
Скачать данные: {downloadUrl}
ВАЖНО: Эта ссылка перестанет работать {expiresAt, date, full} {expiresAt, time, short}
Экспорт включает:
- Все ваши сообщения, отсортированные по каналам
- Метаданные каналов
- Профиль пользователя и данные аккаунта
- Информацию о членстве в гильдиях и настройках
- Сессии аутентификации и сведения о безопасности
Данные предоставляются в формате JSON для удобства анализа.
Если у вас есть вопросы, пишите на support@fluxer.app
- Команда Fluxer`,
},
unbanNotification: {
subject: 'Блокировка вашей учетной записи Fluxer снята',
body: `Здравствуйте, {username},
Хорошие новости! Блокировка вашей учетной записи Fluxer была снята.
Причина: {reason}
Теперь вы можете снова войти в аккаунт и продолжить использование Fluxer.
- Команда безопасности Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Ваша учетная запись Fluxer назначена к удалению',
body: `Здравствуйте, {username},
Ваша учетная запись Fluxer назначена для полной, постоянной ликвидации.
Дата удаления: {deletionDate, date, full} {deletionDate, time, short}
Причина: {reason}
Это серьёзная мера. Все данные аккаунта будут безвозвратно удалены в указанный срок.
Если вы считаете решение ошибочным, вы можете подать апелляцию, написав на appeals@fluxer.app.
- Команда безопасности Fluxer`,
},
giftChargebackNotification: {
subject: 'Ваш подарок Fluxer Premium был отменён',
body: `Здравствуйте, {username},
Мы сообщаем вам, что подарок Fluxer Premium, который вы активировали, был отменён из-за платёжного спора (chargeback), инициированного первоначальным покупателем.
Премиум-функции были удалены из вашей учетной записи, так как оплата была отозвана.
Если у вас есть вопросы, напишите на support@fluxer.app
- Команда Fluxer`,
},
reportResolved: {
subject: 'Ваше обращение в Fluxer было рассмотрено',
body: `Здравствуйте, {username},
Ваше обращение (ID: {reportId}) было рассмотрено нашей Командой безопасности.
Ответ Команды безопасности:
{publicComment}
Спасибо, что помогаете делать Fluxer безопасным для всех. Мы ценим ваш вклад в наше сообщество.
Если у вас есть вопросы или сомнения, напишите на safety@fluxer.app
- Команда безопасности Fluxer`,
},
dsaReportVerification: {
subject: 'Подтвердите ваш email для жалобы по DSA',
body: `Здравствуйте,
Используйте следующий код подтверждения для отправки жалобы в соответствии с Законом о цифровых услугах на Fluxer:
{code}
Срок действия кода истекает {expiresAt, date, full} {expiresAt, time, short}.
Если вы не запрашивали это, просто проигнорируйте это письмо.
- Команда безопасности Fluxer`,
},
registrationApproved: {
subject: 'Ваша регистрация в Fluxer одобрена',
body: `Здравствуйте, {username},
Отличные новости! Ваша регистрация в Fluxer была одобрена.
Теперь вы можете войти в приложение Fluxer здесь:
{channelsUrl}
Добро пожаловать в сообщество Fluxer!
- Команда Fluxer`,
},
emailChangeRevert: {
subject: 'Ваш email Fluxer был изменён',
body: `Здравствуйте, {username}!
Электронная почта вашего аккаунта Fluxer была изменена на {newEmail}.
Если это сделали вы, ничего делать не нужно. Если нет, вы можете отменить изменение и защитить аккаунт по этой ссылке:
{revertUrl}
Это восстановит прежний email, завершит все сеансы, удалит связанные номера телефонов, отключит MFA и потребует новый пароль.
- Команда безопасности Fluxer`,
},
};

View File

@@ -0,0 +1,319 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const svSE: EmailTranslations = {
passwordReset: {
subject: 'Återställ ditt Fluxer-lösenord',
body: `Hej {username},
Du har begärt att återställa lösenordet för ditt Fluxer-konto. Följ länken nedan för att skapa ett nytt lösenord:
{resetUrl}
Om du inte begärde detta kan du ignorera detta mejl.
Länken är giltig i 1 timme.
- Fluxer-teamet`,
},
emailVerification: {
subject: 'Verifiera din e-postadress för Fluxer',
body: `Hej {username},
Verifiera din e-postadress för ditt Fluxer-konto genom att klicka på länken nedan:
{verifyUrl}
Om du inte skapade ett Fluxer-konto kan du bortse från detta mejl.
Länken är giltig i 24 timmar.
- Fluxer-teamet`,
},
ipAuthorization: {
subject: 'Godkänn inloggning från ny IP-adress',
body: `Hej {username},
Vi upptäckte ett inloggningsförsök till ditt Fluxer-konto från en ny IP-adress:
IP-adress: {ipAddress}
Plats: {location}
Om detta var du, godkänn IP-adressen via länken nedan:
{authUrl}
Om du inte försökte logga in bör du omedelbart byta lösenord.
Denna länk upphör att gälla om 30 minuter.
- Fluxer-teamet`,
},
accountDisabledSuspicious: {
subject: 'Ditt Fluxer-konto har tillfälligt inaktiverats',
body: `Hej {username},
Ditt Fluxer-konto har tillfälligt inaktiverats på grund av misstänkt aktivitet.
{reason, select,
null {}
other {Anledning: {reason}
}}För att återfå åtkomst måste du återställa ditt lösenord:
{forgotUrl}
När ditt lösenord är återställt kan du logga in igen.
Om du anser att detta skedde av misstag, kontakta vårt supportteam.
- Fluxers säkerhetsteam`,
},
accountTempBanned: {
subject: 'Ditt Fluxer-konto har tillfälligt spärrats',
body: `Hej {username},
Ditt Fluxer-konto har tillfälligt spärrats för att du brutit mot våra användarvillkor eller riktlinjer för communityt.
Varaktighet: {durationHours, plural,
=1 {1 timme}
other {# timmar}
}
Spärrat till: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {
Anledning: {reason}}
}
Under denna period har du inte åtkomst till ditt konto.
Vi rekommenderar att du läser:
- Användarvillkor: {termsUrl}
- Community-riktlinjer: {guidelinesUrl}
Om du anser att detta beslut är felaktigt eller orättvist kan du överklaga genom att mejla appeals@fluxer.app från denna e-postadress.
Förklara tydligt varför du anser att beslutet är felaktigt. Vi kommer att granska ditt överklagande och återkomma med ett beslut.
- Fluxers säkerhetsteam`,
},
accountScheduledDeletion: {
subject: 'Ditt Fluxer-konto är schemalagt för radering',
body: `Hej {username},
Ditt Fluxer-konto är schemalagt för permanent radering på grund av överträdelser av våra användarvillkor eller community-riktlinjer.
Planerat raderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {
Anledning: {reason}}
}
Detta är en allvarlig åtgärd. Dina kontodata kommer att raderas permanent det angivna datumet.
Vi rekommenderar att du läser:
- Användarvillkor: {termsUrl}
- Community-riktlinjer: {guidelinesUrl}
ÖVERKLAGANDEPROCESS:
Om du anser att detta beslut är felaktigt eller orättvist har du 30 dagar på dig att skicka ett överklagande till appeals@fluxer.app från denna e-postadress.
Inkludera i ditt överklagande:
- En tydlig förklaring till varför du anser att beslutet är felaktigt
- Eventuell relevant bevisning eller sammanhang
En medlem av Fluxers säkerhetsteam kommer att granska ditt ärende och kan skjuta upp raderingen tills ett slutgiltigt beslut tas.
- Fluxers säkerhetsteam`,
},
selfDeletionScheduled: {
subject: 'Radering av ditt Fluxer-konto har schemalagts',
body: `Hej {username},
Vi är ledsna att se dig lämna! Radering av ditt Fluxer-konto har schemalagts.
Planerat raderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
VIKTIGT: Du kan avbryta denna radering när som helst före {deletionDate, date, full} {deletionDate, time, short} genom att logga in igen.
INNAN DU GÅR:
Integritetspanelen under användarinställningar låter dig:
- Radera dina meddelanden på plattformen
- Exportera viktig data innan du lämnar
Observera: När kontot väl är raderat går det inte längre att ta bort meddelanden. Om du vill radera dem måste du göra det innan raderingen slutförs.
Om du ångrar dig kan du bara logga in igen för att avbryta raderingen.
- Fluxer-teamet`,
},
inactivityWarning: {
subject: 'Ditt Fluxer-konto kommer att raderas på grund av inaktivitet',
body: `Hej {username},
Vi har märkt att du inte har loggat in på ditt Fluxer-konto på över 2 år.
Senaste inloggning: {lastActiveDate, date, full} {lastActiveDate, time, short}
Enligt vår policy för datalagring schemaläggs inaktiva konton automatiskt för radering.
Planerat raderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
SÅ HÄR BEHÅLLER DU DITT KONTO:
Logga bara in på {loginUrl} innan raderingsdatumet så avbryts den automatiska raderingen.
OM DU INTE LOGGAR IN:
- Ditt konto och alla tillhörande data kommer att raderas permanent
- Dina meddelanden anonymiseras ("Deleted User")
- Denna åtgärd kan inte ångras
VILL DU SJÄLV RADERA DINA MEDDELANDEN?
Logga in och använd integritetspanelen innan kontot raderas.
Vi hoppas få se dig tillbaka på Fluxer!
- Fluxer-teamet`,
},
harvestCompleted: {
subject: 'Din Fluxer-dataexport är klar',
body: `Hej {username},
Din dataexport har slutförts och är nu redo för nedladdning!
Exportöversikt:
- Totalt antal meddelanden: {totalMessages, number}
- Filstorlek: {fileSizeMB} MB
- Format: ZIP-arkiv med JSON-filer
Ladda ner din data här: {downloadUrl}
VIKTIGT: Denna nedladdningslänk upphör att gälla {expiresAt, date, full} {expiresAt, time, short}
Exporten inkluderar:
- Alla dina meddelanden organiserade per kanal
- Kanalmetadata
- Din profil- och kontoinformation
- Inställningar och medlemskap i guilds
- Autentiseringssessioner och säkerhetsinformation
Data levereras i JSON-format för enkel analys.
Om du har frågor är du välkommen att kontakta support@fluxer.app
- Fluxer-teamet`,
},
unbanNotification: {
subject: 'Din Fluxer-avstängning har hävts',
body: `Hej {username},
Goda nyheter! Avstängningen av ditt Fluxer-konto har hävts.
Anledning: {reason}
Du kan nu logga in och fortsätta använda Fluxer.
- Fluxers säkerhetsteam`,
},
scheduledDeletionNotification: {
subject: 'Ditt Fluxer-konto är planerat för radering',
body: `Hej {username},
Ditt Fluxer-konto är planerat för permanent radering.
Raderingsdatum: {deletionDate, date, full} {deletionDate, time, short}
Anledning: {reason}
Detta är en allvarlig åtgärd — dina kontodata kommer att raderas permanent.
Om du anser att detta är fel kan du skicka ett överklagande till appeals@fluxer.app
- Fluxers säkerhetsteam`,
},
giftChargebackNotification: {
subject: 'Din Fluxer Premium-gåva har återkallats',
body: `Hej {username},
Vi vill informera dig om att din inlösta Fluxer Premium-gåva har återkallats på grund av en betalningstvist (chargeback) initierad av den ursprungliga köparen.
Dina premiumförmåner har tagits bort från kontot. Detta beror på att betalningen återkallats.
Vid frågor kan du kontakta support@fluxer.app
- Fluxer-teamet`,
},
reportResolved: {
subject: 'Din Fluxer-anmälan har behandlats',
body: `Hej {username},
Din anmälan (ID: {reportId}) har nu behandlats av vårt säkerhetsteam.
Säkerhetsteamets svar:
{publicComment}
Tack för att du hjälper till att hålla Fluxer säkert för alla. Vi uppskattar ditt engagemang för vårt community.
Om du har frågor eller funderingar, kontakta safety@fluxer.app
- Fluxers säkerhetsteam`,
},
dsaReportVerification: {
subject: 'Verifiera din e-post för en DSA-anmälan',
body: `Hej,
Använd följande verifieringskod för att skicka in din anmälan enligt lagen om digitala tjänster på Fluxer:
{code}
Denna kod upphör att gälla {expiresAt, date, full} {expiresAt, time, short}.
Om du inte begärde detta kan du ignorera detta mejl.
- Fluxers säkerhetsteam`,
},
registrationApproved: {
subject: 'Din Fluxer-registrering har godkänts',
body: `Hej {username},
Goda nyheter! Din registrering för Fluxer har godkänts.
Du kan nu logga in i Fluxer-appen här:
{channelsUrl}
Välkommen till Fluxer-communityt!
- Fluxer-teamet`,
},
emailChangeRevert: {
subject: 'Din Fluxer-e-post har ändrats',
body: `Hej {username},
E-postadressen för ditt Fluxer-konto har ändrats till {newEmail}.
Om du gjorde ändringen behöver du inte göra något mer. Om inte kan du ångra den och säkra kontot via denna länk:
{revertUrl}
Detta återställer din tidigare e-post, loggar ut dig överallt, tar bort kopplade telefonnummer, inaktiverar MFA och kräver ett nytt lösenord.
- Fluxers säkerhetsteam`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const th: EmailTranslations = {
passwordReset: {
subject: 'รีเซ็ตรหัสผ่าน Fluxer ของคุณ',
body: `สวัสดี {username},
คุณได้ส่งคำขอรีเซ็ตรหัสผ่านสำหรับบัญชี Fluxer ของคุณ โปรดคลิกลิงก์ด้านล่างเพื่อกำหนดรหัสผ่านใหม่:
{resetUrl}
หากคุณไม่ได้ส่งคำขอนี้ คุณสามารถละเว้นอีเมลฉบับนี้ได้อย่างปลอดภัย
ลิงก์นี้จะหมดอายุภายใน 1 ชั่วโมง
- ทีมงาน Fluxer`,
},
emailVerification: {
subject: 'ยืนยันที่อยู่อีเมล Fluxer ของคุณ',
body: `สวัสดี {username},
โปรดยืนยันที่อยู่อีเมลสำหรับบัญชี Fluxer ของคุณโดยคลิกลิงก์ด้านล่าง:
{verifyUrl}
หากคุณไม่ได้สร้างบัญชี Fluxer คุณสามารถละเว้นอีเมลนี้ได้
ลิงก์นี้จะหมดอายุภายใน 24 ชั่วโมง
- ทีมงาน Fluxer`,
},
ipAuthorization: {
subject: 'ยืนยันการเข้าสู่ระบบจาก IP ใหม่',
body: `สวัสดี {username},
เราพบความพยายามเข้าสู่ระบบบัญชี Fluxer ของคุณจาก IP Address ใหม่:
IP Address: {ipAddress}
ตำแหน่ง: {location}
หากเป็นคุณ โปรดยืนยัน IP Address นี้โดยคลิกลิงก์ด้านล่าง:
{authUrl}
หากคุณไม่ได้พยายามเข้าสู่ระบบ โปรดเปลี่ยนรหัสผ่านทันที
ลิงก์สำหรับยืนยันนี้จะหมดอายุใน 30 นาที
- ทีมงาน Fluxer`,
},
accountDisabledSuspicious: {
subject: 'บัญชี Fluxer ของคุณถูกปิดใช้งานชั่วคราว',
body: `สวัสดี {username},
บัญชี Fluxer ของคุณถูกปิดใช้งานชั่วคราวเนื่องจากพบกิจกรรมที่น่าสงสัย
{reason, select,
null {}
other {สาเหตุ: {reason}
}}ในการกู้คืนการเข้าถึง คุณต้องรีเซ็ตรหัสผ่าน:
{forgotUrl}
หลังจากรีเซ็ตรหัสผ่านแล้ว คุณจะสามารถเข้าสู่ระบบได้อีกครั้ง
หากเชื่อว่าการดำเนินการนี้เกิดขึ้นโดยความผิดพลาด โปรดติดต่อทีมสนับสนุนของเรา
- ทีมความปลอดภัย Fluxer`,
},
accountTempBanned: {
subject: 'บัญชี Fluxer ของคุณถูกระงับชั่วคราว',
body: `สวัสดี {username},
บัญชี Fluxer ของคุณถูกระงับชั่วคราวเนื่องจากละเมิดข้อกำหนดการให้บริการหรือแนวทางชุมชนของเรา
ระยะเวลา: {durationHours, plural,
=1 {1 ชั่วโมง}
other {# ชั่วโมง}
}
ถูกระงับจนถึง: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {สาเหตุ: {reason}}
}
ในช่วงระงับนี้ คุณจะไม่สามารถเข้าถึงบัญชีของคุณได้
เราแนะนำให้ทบทวน:
- ข้อกำหนดการให้บริการ: {termsUrl}
- แนวทางชุมชน: {guidelinesUrl}
หากเชื่อว่ามีการบังคับใช้ที่ไม่ถูกต้อง คุณสามารถยื่นอุทธรณ์ได้ที่ appeals@fluxer.app โดยใช้อีเมลนี้
โปรดอธิบายอย่างชัดเจนว่าทำไมคุณเชื่อว่าการตัดสินใจนั้นไม่ถูกต้อง ทีมงานของเราจะตรวจสอบและแจ้งผลให้คุณทราบ
- ทีมความปลอดภัย Fluxer`,
},
accountScheduledDeletion: {
subject: 'บัญชี Fluxer ของคุณมีกำหนดลบ',
body: `สวัสดี {username},
บัญชี Fluxer ของคุณถูกกำหนดให้ถูกลบอย่างถาวรเนื่องจากละเมิดข้อกำหนดการให้บริการหรือแนวทางชุมชน
วันที่กำหนดลบ: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {สาเหตุ: {reason}}
}
นี่เป็นมาตรการที่ร้ายแรง ข้อมูลบัญชีทั้งหมดของคุณจะถูกลบอย่างถาวรในวันที่กำหนด
โปรดทบทวน:
- ข้อกำหนดการให้บริการ: {termsUrl}
- แนวทางชุมชน: {guidelinesUrl}
ขั้นตอนการอุทธรณ์:
หากเชื่อว่าการตัดสินใจนี้ไม่ถูกต้อง คุณมีเวลา 30 วันในการส่งอุทธรณ์ไปยัง appeals@fluxer.app โดยใช้อีเมลนี้
ในอุทธรณ์ของคุณ โปรด:
- อธิบายอย่างชัดเจนว่าการบังคับใช้นี้ไม่ถูกต้องอย่างไร
- แนบหลักฐานหรือข้อมูลที่เกี่ยวข้อง
ทีมความปลอดภัย Fluxer จะตรวจสอบคำอุทธรณ์และอาจระงับการลบจนกว่าจะมีการตัดสินขั้นสุดท้าย
- ทีมความปลอดภัย Fluxer`,
},
selfDeletionScheduled: {
subject: 'การลบบัญชี Fluxer ของคุณถูกตั้งเวลาไว้แล้ว',
body: `สวัสดี {username},
เราเสียใจที่เห็นคุณจากไป! การลบบัญชี Fluxer ของคุณถูกตั้งเวลาไว้แล้ว
วันที่กำหนดลบ: {deletionDate, date, full} {deletionDate, time, short}
สำคัญ: คุณสามารถยกเลิกการลบนี้ได้ทุกเมื่อก่อน {deletionDate, date, full} {deletionDate, time, short} เพียงเข้าสู่ระบบอีกครั้ง
ก่อนที่คุณจะไป:
แดชบอร์ดความเป็นส่วนตัวในเมนูการตั้งค่าผู้ใช้ให้คุณสามารถ:
- ลบข้อความของคุณบนแพลตฟอร์ม
- ดาวน์โหลดข้อมูลสำคัญก่อนออกจากระบบ
โปรดทราบ: หลังจากบัญชีถูกลบ คุณจะไม่สามารถลบข้อความได้อีก หากต้องการลบ โปรดดำเนินการก่อนการลบเสร็จสมบูรณ์
หากคุณเปลี่ยนใจ เพียงเข้าสู่ระบบเพื่อยกเลิกการลบ
- ทีมงาน Fluxer`,
},
inactivityWarning: {
subject: 'บัญชี Fluxer ของคุณจะถูกลบเนื่องจากไม่มีการใช้งาน',
body: `สวัสดี {username},
เราสังเกตว่าคุณไม่ได้เข้าสู่ระบบบัญชี Fluxer มาเป็นเวลากว่า 2 ปี
การเข้าสู่ระบบครั้งล่าสุด: {lastActiveDate, date, full} {lastActiveDate, time, short}
ตามนโยบายการเก็บรักษาข้อมูลของเรา บัญชีที่ไม่มีการใช้งานจะถูกตั้งเวลาเพื่อลบโดยอัตโนมัติ
วันที่กำหนดลบ: {deletionDate, date, full} {deletionDate, time, short}
วิธีเก็บรักษาบัญชีของคุณ:
เพียงเข้าสู่ระบบที่ {loginUrl} ก่อนวันที่ลบ บัญชีของคุณจะไม่ถูกลบ
หากคุณไม่เข้าสู่ระบบ:
- บัญชีและข้อมูลทั้งหมดจะถูกลบถาวร
- ข้อความของคุณจะถูกทำให้ไม่ระบุตัวตน (“ผู้ใช้ที่ถูกลบ”)
- การกระทำนี้ไม่สามารถย้อนกลับได้
ต้องการลบข้อความของคุณหรือไม่?
เพียงเข้าสู่ระบบและใช้แดชบอร์ดความเป็นส่วนตัวก่อนกำหนดลบ
หวังว่าจะได้พบคุณอีกครั้งบน Fluxer!
- ทีมงาน Fluxer`,
},
harvestCompleted: {
subject: 'ข้อมูลส่งออก Fluxer ของคุณพร้อมให้ดาวน์โหลดแล้ว',
body: `สวัสดี {username},
การส่งออกข้อมูลของคุณเสร็จสมบูรณ์และพร้อมให้ดาวน์โหลดแล้ว!
สรุปข้อมูลที่ส่งออก:
- จำนวนข้อความทั้งหมด: {totalMessages, number}
- ขนาดไฟล์: {fileSizeMB} MB
- รูปแบบ: ไฟล์ ZIP ที่มี JSON
ดาวน์โหลดข้อมูลของคุณ: {downloadUrl}
สำคัญ: ลิงก์นี้จะหมดอายุใน {expiresAt, date, full} {expiresAt, time, short}
ข้อมูลที่ส่งออกประกอบด้วย:
- ข้อความทั้งหมดของคุณแบ่งตามช่อง
- ข้อมูลเมตาของช่อง
- โปรไฟล์และข้อมูลบัญชีของคุณ
- การเป็นสมาชิกกิลด์และการตั้งค่า
- เซสชันการยืนยันตัวตนและข้อมูลความปลอดภัย
ข้อมูลอยู่ในรูปแบบ JSON เพื่อความสะดวกในการวิเคราะห์
หากมีคำถาม โปรดติดต่อ support@fluxer.app
- ทีมงาน Fluxer`,
},
unbanNotification: {
subject: 'การระงับบัญชี Fluxer ของคุณถูกยกเลิกแล้ว',
body: `สวัสดี {username},
ข่าวดี! การระงับบัญชี Fluxer ของคุณถูกยกเลิกแล้ว
สาเหตุ: {reason}
คุณสามารถเข้าสู่ระบบและใช้ Fluxer ได้ตามปกติ
- ทีมความปลอดภัย Fluxer`,
},
scheduledDeletionNotification: {
subject: 'บัญชี Fluxer ของคุณมีกำหนดลบ',
body: `สวัสดี {username},
บัญชี Fluxer ของคุณมีกำหนดที่จะถูกลบอย่างถาวร
วันที่กำหนดลบ: {deletionDate, date, full} {deletionDate, time, short}
สาเหตุ: {reason}
นี่เป็นการดำเนินการที่ร้ายแรง ข้อมูลทั้งหมดของคุณจะถูกลบถาวรในวันดังกล่าว
หากคุณเชื่อว่าการตัดสินใจนี้ไม่ถูกต้อง สามารถยื่นอุทธรณ์ได้ที่ appeals@fluxer.app
- ทีมความปลอดภัย Fluxer`,
},
giftChargebackNotification: {
subject: 'ของขวัญ Fluxer Premium ของคุณถูกเพิกถอน',
body: `สวัสดี {username},
เราต้องการแจ้งให้คุณทราบว่าของขวัญ Fluxer Premium ที่คุณแลกรับถูกเพิกถอน เนื่องจากมีข้อพิพาทการชำระเงิน (chargeback) ที่ถูกยื่นโดยผู้ซื้อเดิม
สิทธิประโยชน์แบบพรีเมียมของคุณถูกนำออกจากบัญชีแล้ว เนื่องจากการชำระเงินถูกยกเลิก
หากมีคำถาม โปรดติดต่อ support@fluxer.app
- ทีมงาน Fluxer`,
},
reportResolved: {
subject: 'รายงานของคุณบน Fluxer ได้รับการตรวจสอบแล้ว',
body: `สวัสดี {username},
รายงานของคุณ (ID: {reportId}) ได้รับการตรวจสอบโดยทีมความปลอดภัยของเราแล้ว
คำตอบจากทีมความปลอดภัย:
{publicComment}
ขอบคุณที่ช่วยให้ Fluxer เป็นพื้นที่ที่ปลอดภัยสำหรับทุกคน เราขอขอบคุณในการมีส่วนร่วมของคุณต่อชุมชน
หากมีคำถามหรือข้อกังวล โปรดติดต่อ safety@fluxer.app
- ทีมความปลอดภัย Fluxer`,
},
dsaReportVerification: {
subject: 'ยืนยันอีเมลของคุณสำหรับรายงาน DSA',
body: `สวัสดี,
ใช้รหัสยืนยันต่อไปนี้เพื่อส่งรายงานตามพระราชบัญญัติบริการดิจิทัลบน Fluxer:
{code}
รหัสนี้จะหมดอายุใน {expiresAt, date, full} {expiresAt, time, short}
หากคุณไม่ได้ขอสิ่งนี้ โปรดเพิกเฉยต่ออีเมลนี้
- ทีมความปลอดภัย Fluxer`,
},
registrationApproved: {
subject: 'การลงทะเบียน Fluxer ของคุณได้รับการอนุมัติแล้ว',
body: `สวัสดี {username},
ข่าวดี! การลงทะเบียน Fluxer ของคุณได้รับการอนุมัติแล้ว
คุณสามารถเข้าสู่แอป Fluxer ได้ที่:
{channelsUrl}
ยินดีต้อนรับสู่ชุมชน Fluxer!
- ทีมงาน Fluxer`,
},
emailChangeRevert: {
subject: 'อีเมล Fluxer ของคุณถูกเปลี่ยนแล้ว',
body: `สวัสดี {username},
อีเมลของบัญชี Fluxer ของคุณถูกเปลี่ยนเป็น {newEmail}.
หากคุณเป็นผู้เปลี่ยน ไม่ต้องดำเนินการใด ๆ เพิ่มเติม หากไม่ใช่ คุณสามารถย้อนกลับและปกป้องบัญชีได้ผ่านลิงก์นี้:
{revertUrl}
การดำเนินการนี้จะกู้คืนอีเมลเดิมของคุณ ออกจากระบบทุกเซสชัน ลบหมายเลขโทรศัพท์ที่เชื่อมไว้ ปิดใช้งาน MFA และต้องตั้งรหัสผ่านใหม่
- ทีมความปลอดภัย Fluxer`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const tr: EmailTranslations = {
passwordReset: {
subject: 'Fluxer şifrenizi sıfırlayın',
body: `Merhaba {username},
Fluxer hesabınız için bir şifre sıfırlama isteğinde bulundunuz. Yeni bir şifre oluşturmak için aşağıdaki bağlantıyı takip edin:
{resetUrl}
Bu isteği siz yapmadıysanız, bu e-postayı güvenle yok sayabilirsiniz.
Bu bağlantı 1 saat içinde geçerliliğini yitirecektir.
- Fluxer Ekibi`,
},
emailVerification: {
subject: 'Fluxer e-posta adresinizi doğrulayın',
body: `Merhaba {username},
Fluxer hesabınıza bağlı e-posta adresinizi doğrulamak için aşağıdaki bağlantıya tıklayın:
{verifyUrl}
Eğer bir Fluxer hesabı oluşturmadıysanız bu e-postayı yok sayabilirsiniz.
Bu bağlantı 24 saat içinde geçerliliğini yitirecektir.
- Fluxer Ekibi`,
},
ipAuthorization: {
subject: 'Yeni bir IP adresinden giriş izni',
body: `Merhaba {username},
Fluxer hesabınıza yeni bir IP adresinden giriş yapılmaya çalışıldığını tespit ettik:
IP Adresi: {ipAddress}
Konum: {location}
Eğer bu giriş sizdenseniz, IP adresini onaylamak için aşağıdaki bağlantıya tıklayın:
{authUrl}
Eğer giriş yapmaya çalışan siz değilseniz, lütfen şifrenizi hemen değiştirin.
Bu doğrulama bağlantısı 30 dakika içinde geçerliliğini yitirecektir.
- Fluxer Ekibi`,
},
accountDisabledSuspicious: {
subject: 'Fluxer hesabınız geçici olarak devre dışı bırakıldı',
body: `Merhaba {username},
Şüpheli etkinlik nedeniyle Fluxer hesabınız geçici olarak devre dışı bırakıldı.
{reason, select,
null {}
other {Sebep: {reason}
}}Hesabınıza yeniden erişmek için şifrenizi sıfırlamanız gerekmektedir:
{forgotUrl}
Şifrenizi sıfırladıktan sonra tekrar giriş yapabilirsiniz.
Bu işlemin hata sonucu gerçekleştiğini düşünüyorsanız, lütfen destek ekibimizle iletişime geçin.
- Fluxer Güvenlik Ekibi`,
},
accountTempBanned: {
subject: 'Fluxer hesabınız geçici olarak askıya alındı',
body: `Merhaba {username},
Fluxer hesabınız Hizmet Şartları veya Topluluk Kurallarını ihlal ettiğiniz için geçici olarak askıya alındı.
Süre: {durationHours, plural,
=1 {1 saat}
other {# saat}
}
Askıya alınma bitiş tarihi: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {Sebep: {reason}}
}
Bu süre boyunca hesabınıza erişemeyeceksiniz.
Lütfen aşağıdakileri gözden geçirin:
- Hizmet Şartları: {termsUrl}
- Topluluk Kuralları: {guidelinesUrl}
Eğer bu yaptırımın hatalı ya da haksız olduğunu düşünüyorsanız, bu e-posta adresi üzerinden appeals@fluxer.app adresine bir itiraz gönderebilirsiniz.
Neden hatalı olduğunu düşündüğünüzü açıkça açıklayın. İtirazınızı inceleyerek size geri dönüş yapacağız.
- Fluxer Güvenlik Ekibi`,
},
accountScheduledDeletion: {
subject: 'Fluxer hesabınız silinmek üzere planlandı',
body: `Merhaba {username},
Fluxer hesabınız Hizmet Şartları veya Topluluk Kurallarını ihlal ettiğiniz için kalıcı olarak silinmek üzere planlandı.
Planlanan silinme tarihi: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {Sebep: {reason}}
}
Bu ciddi bir yaptırımdır. Hesap verileriniz belirtilen tarihte kalıcı olarak silinecektir.
Lütfen aşağıdakileri gözden geçirin:
- Hizmet Şartları: {termsUrl}
- Topluluk Kuralları: {guidelinesUrl}
İTİRAZ SÜRECİ:
Bu kararın hatalı ya da haksız olduğunu düşünüyorsanız, bu e-postayı kullanarak 30 gün içinde appeals@fluxer.app adresine bir itiraz gönderebilirsiniz.
İtirazınızda:
- Kararın neden yanlış olduğunu düşündüğünüzü açıklayın
- İlgili kanıt veya bağlam sunun
Fluxer Güvenlik Ekibi itirazınızı inceleyecek ve nihai karar verilene kadar silme işlemini durdurabilir.
- Fluxer Güvenlik Ekibi`,
},
selfDeletionScheduled: {
subject: 'Fluxer hesabınızın silinmesi planlandı',
body: `Merhaba {username},
Sizi kaybettiğimiz için üzgünüz! Fluxer hesabınızın silinmesi planlanmıştır.
Planlanan silinme tarihi: {deletionDate, date, full} {deletionDate, time, short}
ÖNEMLİ: {deletionDate, date, full} {deletionDate, time, short} tarihinden önce tekrar giriş yaparak bu işlemi iptal edebilirsiniz.
GİTMEDEN ÖNCE:
Kullanıcı Ayarları'ndaki Gizlilik Paneli şu işlemleri yapmanıza izin verir:
- Platformdaki mesajlarınızı silmek
- Ayrılmadan önce önemli verilerinizi dışa aktarmak
Lütfen dikkat: Hesap silindikten sonra mesajları silmeniz mümkün olmayacaktır. Mesajlarınızı silmek istiyorsanız bunu hesap tamamen silinmeden önce yapın.
Fikrinizi değiştirirseniz, tekrar giriş yapmanız yeterlidir.
- Fluxer Ekibi`,
},
inactivityWarning: {
subject: 'Fluxer hesabınız hareketsizlik nedeniyle silinecek',
body: `Merhaba {username},
Fluxer hesabınıza 2 yıldan uzun süredir giriş yapmadığınızı fark ettik.
Son giriş: {lastActiveDate, date, full} {lastActiveDate, time, short}
Veri saklama politikamız gereği, hareketsiz hesaplar otomatik olarak silinmek üzere planlanır.
Planlanan silinme tarihi: {deletionDate, date, full} {deletionDate, time, short}
HESABINIZI KORUMAK İÇİN:
Silme tarihinden önce {loginUrl} adresine giriş yapmanız yeterlidir. Başka bir işlem gerekmez.
EĞER GİRİŞ YAPMAZSANIZ:
- Hesabınız ve tüm verileriniz kalıcı olarak silinir
- Mesajlarınız anonim hale getirilir (“Silinmiş Kullanıcı”)
- Bu işlem geri alınamaz
MESAJLARINIZI ÖNCEDEN SİLMEK İSTER MİSİNİZ?
Silme işleminden önce giriş yaparak Gizlilik Paneli'ni kullanabilirsiniz.
Sizi Fluxer'da tekrar görmeyi umuyoruz!
- Fluxer Ekibi`,
},
harvestCompleted: {
subject: 'Fluxer veri dışa aktarımınız hazır',
body: `Merhaba {username},
Fluxer veri dışa aktarımınız tamamlandı ve indirmeye hazır!
Dışa aktarma özeti:
- Toplam mesaj sayısı: {totalMessages, number}
- Dosya boyutu: {fileSizeMB} MB
- Format: JSON dosyaları içeren ZIP arşivi
Verilerinizi indirin: {downloadUrl}
ÖNEMLİ: Bu indirme bağlantısı {expiresAt, date, full} {expiresAt, time, short} tarihinde sona erecektir.
Dışa aktarma şunları içerir:
- Tüm mesajlarınız (kanallara göre düzenlenmiş)
- Kanal meta verileri
- Kullanıcı profiliniz ve hesap bilgileriniz
- Guild üyelikleri ve ayarlarınız
- Kimlik doğrulama oturumları ve güvenlik bilgileri
Veriler JSON formatında sunulmaktadır.
Sorularınız varsa support@fluxer.app adresine yazabilirsiniz.
- Fluxer Ekibi`,
},
unbanNotification: {
subject: 'Fluxer hesabınıza uygulanan yasak kaldırıldı',
body: `Merhaba {username},
Harika haber! Fluxer hesabınıza uygulanan yasak kaldırıldı.
Sebep: {reason}
Artık tekrar giriş yapabilir ve Fluxer'ı kullanmaya devam edebilirsiniz.
- Fluxer Güvenlik Ekibi`,
},
scheduledDeletionNotification: {
subject: 'Fluxer hesabınız silinmek üzere planlandı',
body: `Merhaba {username},
Fluxer hesabınız kalıcı olarak silinmek üzere planlandı.
Silme tarihi: {deletionDate, date, full} {deletionDate, time, short}
Sebep: {reason}
Bu ciddi bir işlemdir ve hesabınızdaki tüm veriler belirtilen tarihte silinecektir.
Bu kararın hatalı olduğunu düşünüyorsanız appeals@fluxer.app adresine yazabilirsiniz.
- Fluxer Güvenlik Ekibi`,
},
giftChargebackNotification: {
subject: 'Fluxer Premium hediyeniz iptal edildi',
body: `Merhaba {username},
Orijinal satın alıcı tarafından yapılan bir ödeme itirazı (chargeback) nedeniyle kullanmış olduğunuz Fluxer Premium hediyesi iptal edilmiştir.
Premium avantajlarınız hesabınızdan kaldırılmıştır. Bu, ödemenin geri alınması nedeniyle gerçekleştirilmiştir.
Sorularınız varsa support@fluxer.app adresine yazabilirsiniz.
- Fluxer Ekibi`,
},
reportResolved: {
subject: 'Fluxer raporunuz incelendi',
body: `Merhaba {username},
(ID: {reportId}) numaralı raporunuz Fluxer Güvenlik Ekibi tarafından incelenmiştir.
Güvenlik Ekibinin Yanıtı:
{publicComment}
Fluxer'ı herkes için güvenli bir ortam haline getirmeye yardımcı olduğunuz için teşekkür ederiz. Katkılarınızı takdir ediyoruz.
Herhangi bir sorunuz veya endişeniz olursa safety@fluxer.app adresine yazabilirsiniz.
- Fluxer Güvenlik Ekibi`,
},
dsaReportVerification: {
subject: 'DSA bildirimi için e-posta adresinizi doğrulayın',
body: `Merhaba,
Fluxer'da Dijital Hizmetler Yasası bildirimi göndermek için aşağıdaki doğrulama kodunu kullanın:
{code}
Bu kod {expiresAt, date, full} {expiresAt, time, short} tarihinde geçerliliğini yitirecektir.
Eğer bu isteği siz yapmadıysanız, lütfen bu e-postayı görmezden gelin.
- Fluxer Güvenlik Ekibi`,
},
registrationApproved: {
subject: 'Fluxer kaydınız onaylandı',
body: `Merhaba {username},
Harika haber! Fluxer kaydınız onaylandı.
Artık Fluxer uygulamasına giriş yapabilirsiniz:
{channelsUrl}
Fluxer topluluğuna hoş geldiniz!
- Fluxer Ekibi`,
},
emailChangeRevert: {
subject: 'Fluxer e-postan değiştirildi',
body: `Merhaba {username},
Fluxer hesabının e-postası {newEmail} olarak değiştirildi.
Bu değişikliği sen yaptıysan başka bir işlem gerekmez. Yapmadıysan, aşağıdaki bağlantıyla geri alıp hesabını güvene alabilirsin:
{revertUrl}
Bu işlem önceki e-postanı geri getirir, tüm oturumlardan çıkış yapar, bağlı telefon numaralarını kaldırır, MFAyı devre dışı bırakır ve yeni bir parola gerektirir.
- Fluxer Güvenlik Ekibi`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const uk: EmailTranslations = {
passwordReset: {
subject: 'Скидання пароля Fluxer',
body: `Вітаємо, {username},
Ви надіслали запит на скидання пароля для вашого облікового запису Fluxer. Перейдіть за посиланням нижче, щоб встановити новий пароль:
{resetUrl}
Якщо ви не надсилали цей запит, просто проігноруйте цей лист.
Це посилання буде дійсним протягом 1 години.
— Команда Fluxer`,
},
emailVerification: {
subject: 'Підтвердження вашої електронної адреси Fluxer',
body: `Вітаємо, {username},
Будь ласка, підтвердьте електронну адресу вашого облікового запису Fluxer, перейшовши за посиланням нижче:
{verifyUrl}
Якщо ви не створювали обліковий запис Fluxer, просто проігноруйте цей лист.
Це посилання буде дійсним протягом 24 годин.
— Команда Fluxer`,
},
ipAuthorization: {
subject: 'Підтвердіть вхід з нової IP-адреси',
body: `Вітаємо, {username},
Ми виявили спробу входу у ваш обліковий запис Fluxer з нової IP-адреси:
IP-адреса: {ipAddress}
Розташування: {location}
Якщо це були ви, підтвердьте нову IP-адресу за посиланням:
{authUrl}
Якщо це були не ви, негайно змініть пароль.
Це посилання для підтвердження дійсне протягом 30 хвилин.
— Команда Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Ваш обліковий запис Fluxer тимчасово заблоковано',
body: `Вітаємо, {username},
Ваш обліковий запис Fluxer тимчасово заблоковано через підозрілу активність.
{reason, select,
null {}
other {Причина: {reason}
}}Щоб відновити доступ, ви повинні скинути пароль:
{forgotUrl}
Після скидання пароля ви зможете знову увійти до системи.
Якщо ви вважаєте, що це помилка, зверніться до служби підтримки.
— Команда безпеки Fluxer`,
},
accountTempBanned: {
subject: 'Ваш обліковий запис Fluxer тимчасово призупинено',
body: `Вітаємо, {username},
Доступ до вашого облікового запису Fluxer тимчасово призупинено через порушення наших Умов використання або Правил спільноти.
Тривалість: {durationHours, plural,
=1 {1 година}
other {# годин}
}
Призупинено до: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {Причина: {reason}}
}
У цей період ви не зможете користуватися своїм обліковим записом.
Рекомендуємо ознайомитися з:
- Умовами використання: {termsUrl}
- Правилами спільноти: {guidelinesUrl}
Якщо ви вважаєте це рішення помилковим або несправедливим, ви можете надіслати апеляцію на адресу appeals@fluxer.app з цієї електронної пошти.
Будь ласка, детально поясніть, чому ви вважаєте це рішення неправильним. Ми розглянемо вашу апеляцію та повідомимо про результат.
— Команда безпеки Fluxer`,
},
accountScheduledDeletion: {
subject: 'Ваш обліковий запис Fluxer заплановано на видалення',
body: `Вітаємо, {username},
Ваш обліковий запис Fluxer заплановано до остаточного видалення через порушення наших Умов використання або Правил спільноти.
Дата запланованого видалення: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {Причина: {reason}}
}
Це серйозний захід. Усі дані вашого облікового запису буде безповоротно видалено у зазначений день.
Рекомендуємо ознайомитися з:
- Умовами використання: {termsUrl}
- Правилами спільноти: {guidelinesUrl}
ПРОЦЕС АПЕЛЯЦІЇ:
Якщо ви вважаєте це рішення помилковим або несправедливим, у вас є 30 днів, щоб надіслати апеляцію на адресу appeals@fluxer.app.
У вашій апеляції:
- Поясніть, чому рішення є неправильним або несправедливим
- Додайте будь-які докази або важливий контекст
Команда безпеки Fluxer розгляне апеляцію та може тимчасово зупинити видалення до остаточного рішення.
— Команда безпеки Fluxer`,
},
selfDeletionScheduled: {
subject: 'Видалення вашого облікового запису Fluxer заплановано',
body: `Вітаємо, {username},
Нам прикро, що ви вирішили піти! Видалення вашого облікового запису Fluxer заплановано.
Дата видалення: {deletionDate, date, full} {deletionDate, time, short}
ВАЖЛИВО: Ви можете скасувати видалення в будь-який момент до {deletionDate, date, full} {deletionDate, time, short}, просто увійшовши до свого облікового запису.
ПЕРЕД ТИМ ЯК ПІТИ:
Панель конфіденційності в налаштуваннях користувача дозволяє:
- Видалити ваші повідомлення на платформі
- Завантажити ваші дані перед видаленням
Зверніть увагу: після видалення облікового запису повідомлення не можна буде видалити. Якщо хочете це зробити — зробіть заздалегідь.
Якщо ви передумаєте, просто увійдіть знову.
— Команда Fluxer`,
},
inactivityWarning: {
subject: 'Ваш обліковий запис Fluxer буде видалено через неактивність',
body: `Вітаємо, {username},
Ми помітили, що ви не входили у свій обліковий запис Fluxer понад 2 роки.
Останній вхід: {lastActiveDate, date, full} {lastActiveDate, time, short}
Згідно з нашою політикою зберігання даних, неактивні облікові записи автоматично плануються для видалення.
Дата видалення: {deletionDate, date, full} {deletionDate, time, short}
ЯК ЗБЕРЕГТИ ВАШ ОБЛІКОВИЙ ЗАПИС:
Просто увійдіть у систему за адресою {loginUrl} до дати видалення — і обліковий запис не буде видалено.
ЯКЩО ВИ НЕ УВІЙДЕТЕ:
- Ваш обліковий запис і дані будуть остаточно видалені
- Ваші повідомлення буде анонімізовано (“Видалений користувач”)
- Цю дію неможливо буде скасувати
ХОЧЕТЕ СПОЧАТКУ ВИДАЛИТИ ПОВІДОМЛЕННЯ?
Просто увійдіть і використайте Панель конфіденційності.
Сподіваємося побачити вас знову у Fluxer!
— Команда Fluxer`,
},
harvestCompleted: {
subject: 'Ваш експорт даних Fluxer готовий',
body: `Вітаємо, {username},
Експорт ваших даних завершено та доступний для завантаження!
Підсумок експорту:
- Загальна кількість повідомлень: {totalMessages, number}
- Розмір файлу: {fileSizeMB} MB
- Формат: ZIP-архів із JSON-файлами
Завантажити дані: {downloadUrl}
ВАЖЛИВО: Це посилання буде дійсним до {expiresAt, date, full} {expiresAt, time, short}
Експорт містить:
- Усі ваші повідомлення, впорядковані за каналами
- Метадані каналів
- Інформацію профілю та облікового запису
- Налаштування та членства в гільдіях
- Сеанси автентифікації та дані безпеки
Усі дані надано у форматі JSON для зручності аналізу.
Якщо у вас виникнуть запитання, напишіть нам: support@fluxer.app
— Команда Fluxer`,
},
unbanNotification: {
subject: 'Призупинення вашого облікового запису Fluxer скасовано',
body: `Вітаємо, {username},
Добрі новини! Призупинення вашого облікового запису Fluxer було скасовано.
Причина: {reason}
Тепер ви можете знову увійти та продовжити користуватися Fluxer.
— Команда безпеки Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Ваш обліковий запис Fluxer заплановано до видалення',
body: `Вітаємо, {username},
Ваш обліковий запис Fluxer заплановано для остаточного видалення.
Дата видалення: {deletionDate, date, full} {deletionDate, time, short}
Причина: {reason}
Це серйозне рішення. Усі дані вашого облікового запису буде видалено.
Якщо ви вважаєте це рішення помилковим, ви можете подати апеляцію на адресу appeals@fluxer.app
— Команда безпеки Fluxer`,
},
giftChargebackNotification: {
subject: 'Ваш подарунок Fluxer Premium було відкликано',
body: `Вітаємо, {username},
Ми повідомляємо вам, що подарунок Fluxer Premium, який ви активували, було відкликано через платіжний спір (chargeback), ініційований початковим покупцем.
Ваші преміум-переваги були видалені з облікового запису, оскільки платіж було скасовано.
Якщо у вас є запитання, напишіть нам: support@fluxer.app
— Команда Fluxer`,
},
reportResolved: {
subject: 'Ваш звіт до Fluxer розглянуто',
body: `Вітаємо, {username},
Ваш звіт (ID: {reportId}) був розглянутий нашою Командою безпеки.
Відповідь Команди безпеки:
{publicComment}
Дякуємо, що допомагаєте підтримувати безпеку у Fluxer. Ми цінуємо ваш внесок у спільноту.
Якщо у вас є запитання чи сумніви — напишіть нам: safety@fluxer.app
— Команда безпеки Fluxer`,
},
dsaReportVerification: {
subject: 'Підтвердіть вашу електронну адресу для звіту DSA',
body: `Вітаємо,
Використайте наступний код підтвердження для подання звіту за Законом про цифрові послуги на Fluxer:
{code}
Цей код дійсний до {expiresAt, date, full} {expiresAt, time, short}.
Якщо ви не надсилали цей запит, просто проігноруйте цей лист.
— Команда безпеки Fluxer`,
},
registrationApproved: {
subject: 'Вашу реєстрацію в Fluxer підтверджено',
body: `Вітаємо, {username},
Чудові новини! Вашу реєстрацію в Fluxer схвалено.
Тепер ви можете увійти в застосунок Fluxer за посиланням:
{channelsUrl}
Ласкаво просимо до спільноти Fluxer!
— Команда Fluxer`,
},
emailChangeRevert: {
subject: 'Вашу адресу електронної пошти Fluxer змінено',
body: `Вітаємо, {username}!
Адресу електронної пошти вашого облікового запису Fluxer змінено на {newEmail}.
Якщо це зробили ви, нічого робити не потрібно. Якщо ні — скористайтеся цим посиланням, щоб скасувати зміну та захистити обліковий запис:
{revertUrl}
Це відновить попередню адресу, виведе вас з усіх сесій, видалить прив'язані номери телефонів, вимкне MFA та потребуватиме нового пароля.
- Команда безпеки Fluxer`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const vi: EmailTranslations = {
passwordReset: {
subject: 'Đặt lại mật khẩu Fluxer của bạn',
body: `Xin chào {username},
Bạn đã yêu cầu đặt lại mật khẩu cho tài khoản Fluxer của mình. Vui lòng nhấn vào liên kết bên dưới để đặt mật khẩu mới:
{resetUrl}
Nếu bạn không yêu cầu đặt lại mật khẩu, bạn có thể bỏ qua email này.
Liên kết này sẽ hết hạn sau 1 giờ.
- Đội ngũ Fluxer`,
},
emailVerification: {
subject: 'Xác minh địa chỉ email Fluxer của bạn',
body: `Xin chào {username},
Vui lòng xác minh địa chỉ email cho tài khoản Fluxer của bạn bằng cách nhấn vào liên kết bên dưới:
{verifyUrl}
Nếu bạn không tạo tài khoản Fluxer, bạn có thể bỏ qua email này.
Liên kết này sẽ hết hạn sau 24 giờ.
- Đội ngũ Fluxer`,
},
ipAuthorization: {
subject: 'Xác thực đăng nhập từ địa chỉ IP mới',
body: `Xin chào {username},
Chúng tôi phát hiện một nỗ lực đăng nhập vào tài khoản Fluxer của bạn từ địa chỉ IP mới:
Địa chỉ IP: {ipAddress}
Vị trí: {location}
Nếu đây là bạn, hãy nhấn vào liên kết bên dưới để xác thực IP này:
{authUrl}
Nếu bạn không cố gắng đăng nhập, hãy thay đổi mật khẩu ngay lập tức.
Liên kết xác thực này sẽ hết hạn sau 30 phút.
- Đội ngũ Fluxer`,
},
accountDisabledSuspicious: {
subject: 'Tài khoản Fluxer của bạn đã bị vô hiệu hóa tạm thời',
body: `Xin chào {username},
Tài khoản Fluxer của bạn đã bị vô hiệu hóa tạm thời do hoạt động bất thường.
{reason, select,
null {}
other {Lý do: {reason}
}}Để khôi phục quyền truy cập, bạn cần đặt lại mật khẩu:
{forgotUrl}
Sau khi đặt lại mật khẩu, bạn có thể đăng nhập lại.
Nếu bạn tin rằng đây là nhầm lẫn, vui lòng liên hệ với đội hỗ trợ của chúng tôi.
- Đội An Toàn Fluxer`,
},
accountTempBanned: {
subject: 'Tài khoản Fluxer của bạn đã bị tạm đình chỉ',
body: `Xin chào {username},
Tài khoản Fluxer của bạn đã bị tạm đình chỉ vì vi phạm Điều khoản Dịch vụ hoặc Hướng dẫn Cộng đồng của chúng tôi.
Thời gian đình chỉ: {durationHours, plural,
=1 {1 giờ}
other {# giờ}
}
Đình chỉ đến: {bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {Lý do: {reason}}
}
Trong thời gian này, bạn sẽ không thể truy cập tài khoản của mình.
Chúng tôi khuyến nghị bạn xem lại:
- Điều khoản Dịch vụ: {termsUrl}
- Hướng dẫn Cộng đồng: {guidelinesUrl}
Nếu bạn tin rằng quyết định này không chính xác hoặc không công bằng, bạn có thể gửi đơn khiếu nại đến appeals@fluxer.app từ địa chỉ email này.
Hãy giải thích rõ lý do tại sao bạn tin rằng quyết định này sai. Chúng tôi sẽ xem xét và phản hồi.
- Đội An Toàn Fluxer`,
},
accountScheduledDeletion: {
subject: 'Tài khoản Fluxer của bạn đã được lên lịch xóa',
body: `Xin chào {username},
Tài khoản Fluxer của bạn đã được lên lịch xóa vĩnh viễn vì vi phạm Điều khoản Dịch vụ hoặc Hướng dẫn Cộng đồng.
Ngày xóa dự kiến: {deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {Lý do: {reason}}
}
Đây là một biện pháp nghiêm trọng. Tất cả dữ liệu tài khoản của bạn sẽ bị xóa vĩnh viễn vào ngày đã định.
Chúng tôi khuyến nghị bạn xem lại:
- Điều khoản Dịch vụ: {termsUrl}
- Hướng dẫn Cộng đồng: {guidelinesUrl}
QUY TRÌNH KHIẾU NẠI:
Nếu bạn tin rằng quyết định này không đúng hoặc không công bằng, bạn có 30 ngày để gửi đơn khiếu nại đến appeals@fluxer.app từ email này.
Đơn khiếu nại nên bao gồm:
- Giải thích rõ vì sao bạn tin quyết định là sai
- Bất kỳ thông tin hoặc bằng chứng liên quan
Một thành viên Đội An Toàn Fluxer sẽ xem xét đơn khiếu nại của bạn và có thể hoãn việc xóa cho đến khi có quyết định cuối cùng.
- Đội An Toàn Fluxer`,
},
selfDeletionScheduled: {
subject: 'Việc xóa tài khoản Fluxer của bạn đã được lên lịch',
body: `Xin chào {username},
Chúng tôi rất tiếc khi thấy bạn rời đi! Việc xóa tài khoản Fluxer của bạn đã được lên lịch.
Ngày xóa dự kiến: {deletionDate, date, full} {deletionDate, time, short}
QUAN TRỌNG: Bạn có thể hủy việc xóa này bất cứ lúc nào trước {deletionDate, date, full} {deletionDate, time, short} chỉ bằng cách đăng nhập lại vào tài khoản.
TRƯỚC KHI BẠN RỜI ĐI:
Bảng Điều Khiển Quyền Riêng Tư cho phép bạn:
- Xóa tin nhắn của mình trên nền tảng
- Xuất dữ liệu quan trọng trước khi rời đi
Lưu ý: Sau khi tài khoản bị xóa, bạn sẽ không thể xóa tin nhắn nữa. Nếu bạn muốn xóa chúng, hãy thực hiện trước khi quá trình xóa hoàn tất.
Nếu bạn thay đổi ý định, chỉ cần đăng nhập lại để hủy việc xóa.
- Đội ngũ Fluxer`,
},
inactivityWarning: {
subject: 'Tài khoản Fluxer của bạn sẽ bị xóa do không hoạt động',
body: `Xin chào {username},
Chúng tôi nhận thấy bạn đã không đăng nhập vào tài khoản Fluxer của mình hơn 2 năm.
Lần đăng nhập cuối: {lastActiveDate, date, full} {lastActiveDate, time, short}
Theo chính sách lưu trữ dữ liệu của chúng tôi, các tài khoản không hoạt động sẽ được lên lịch xóa tự động.
Ngày xóa dự kiến: {deletionDate, date, full} {deletionDate, time, short}
CÁCH GIỮ TÀI KHOẢN CỦA BẠN:
Chỉ cần đăng nhập vào {loginUrl} trước ngày xóa để hủy quá trình tự động này.
NẾU BẠN KHÔNG ĐĂNG NHẬP:
- Tài khoản và toàn bộ dữ liệu của bạn sẽ bị xóa vĩnh viễn
- Tin nhắn của bạn sẽ được ẩn danh (“Người dùng đã xóa”)
- Hành động này là không thể hoàn tác
MUỐN XÓA TIN NHẮN CỦA BẠN?
Hãy đăng nhập và sử dụng Bảng Điều Khiển Quyền Riêng Tư trước khi tài khoản bị xóa.
Hy vọng sẽ được gặp lại bạn trên Fluxer!
- Đội ngũ Fluxer`,
},
harvestCompleted: {
subject: 'Xuất dữ liệu Fluxer của bạn đã sẵn sàng',
body: `Xin chào {username},
Quá trình xuất dữ liệu của bạn đã hoàn tất và sẵn sàng để tải xuống!
Tóm tắt xuất dữ liệu:
- Tổng số tin nhắn: {totalMessages, number}
- Kích thước tệp: {fileSizeMB} MB
- Định dạng: Tệp ZIP bao gồm các tệp JSON
Tải xuống dữ liệu của bạn: {downloadUrl}
LƯU Ý: Liên kết này sẽ hết hạn vào {expiresAt, date, full} {expiresAt, time, short}
Gói dữ liệu bao gồm:
- Tất cả tin nhắn của bạn theo từng kênh
- Siêu dữ liệu kênh
- Hồ sơ và thông tin tài khoản của bạn
- Thành viên guild và cài đặt
- Phiên đăng nhập và thông tin bảo mật
Dữ liệu được cung cấp dưới định dạng JSON để dễ dàng phân tích.
Nếu bạn có bất kỳ câu hỏi nào, vui lòng liên hệ support@fluxer.app
- Đội ngũ Fluxer`,
},
unbanNotification: {
subject: 'Tài khoản Fluxer của bạn đã được gỡ khóa',
body: `Xin chào {username},
Tin vui! Việc đình chỉ tài khoản Fluxer của bạn đã được gỡ bỏ.
Lý do: {reason}
Bạn có thể đăng nhập lại và tiếp tục sử dụng Fluxer.
- Đội An Toàn Fluxer`,
},
scheduledDeletionNotification: {
subject: 'Tài khoản Fluxer của bạn đã được lên lịch xóa',
body: `Xin chào {username},
Tài khoản Fluxer của bạn đã được lên lịch để xóa vĩnh viễn.
Ngày xóa: {deletionDate, date, full} {deletionDate, time, short}
Lý do: {reason}
Đây là một biện pháp nghiêm trọng. Tài khoản của bạn sẽ bị xóa hoàn toàn vào ngày trên.
Nếu bạn tin rằng việc này là sai, bạn có thể gửi khiếu nại đến appeals@fluxer.app
- Đội An Toàn Fluxer`,
},
giftChargebackNotification: {
subject: 'Quà tặng Fluxer Premium của bạn đã bị thu hồi',
body: `Xin chào {username},
Chúng tôi xin thông báo rằng quà tặng Fluxer Premium mà bạn đã kích hoạt đã bị thu hồi do tranh chấp thanh toán (chargeback) từ người mua ban đầu.
Các quyền lợi Premium đã bị xóa khỏi tài khoản của bạn.
Nếu bạn có thắc mắc, vui lòng liên hệ support@fluxer.app
- Đội ngũ Fluxer`,
},
reportResolved: {
subject: 'Báo cáo Fluxer của bạn đã được xem xét',
body: `Xin chào {username},
Báo cáo của bạn (ID: {reportId}) đã được đội ngũ An Toàn Fluxer xem xét.
Phản hồi từ đội ngũ:
{publicComment}
Cảm ơn bạn đã đóng góp để giữ Fluxer an toàn cho cộng đồng. Chúng tôi trân trọng sự đóng góp của bạn.
Nếu bạn có câu hỏi hoặc lo ngại, hãy liên hệ safety@fluxer.app
- Đội An Toàn Fluxer`,
},
dsaReportVerification: {
subject: 'Xác minh email của bạn cho báo cáo DSA',
body: `Xin chào,
Sử dụng mã xác minh sau để gửi báo cáo Đạo luật Dịch vụ Kỹ thuật số của bạn trên Fluxer:
{code}
Mã này sẽ hết hạn vào {expiresAt, date, full} {expiresAt, time, short}.
Nếu bạn không yêu cầu điều này, vui lòng bỏ qua email này.
- Đội An Toàn Fluxer`,
},
registrationApproved: {
subject: 'Đăng ký Fluxer của bạn đã được phê duyệt',
body: `Xin chào {username},
Tin vui! Việc đăng ký Fluxer của bạn đã được phê duyệt.
Bạn có thể đăng nhập ứng dụng Fluxer tại:
{channelsUrl}
Chào mừng bạn đến với cộng đồng Fluxer!
- Đội ngũ Fluxer`,
},
emailChangeRevert: {
subject: 'Email Fluxer của bạn đã được thay đổi',
body: `Xin chào {username},
Email tài khoản Fluxer của bạn đã được thay đổi thành {newEmail}.
Nếu bạn tự thay đổi, bạn không cần làm gì thêm. Nếu không, hãy hoàn tác và bảo vệ tài khoản bằng liên kết này:
{revertUrl}
Việc này sẽ khôi phục email trước đó, đăng xuất bạn khỏi mọi phiên, xóa số điện thoại liên kết, tắt MFA và yêu cầu mật khẩu mới.
- Đội ngũ An ninh Fluxer`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const zhCN: EmailTranslations = {
passwordReset: {
subject: '重置你的 Fluxer 密码',
body: `你好,{username}
你请求重置 Fluxer 账户密码。请点击以下链接设置新密码:
{resetUrl}
如果这不是你本人操作,请忽略此邮件。
该链接将于 1 小时后失效。
- Fluxer 团队`,
},
emailVerification: {
subject: '验证你的 Fluxer 邮箱地址',
body: `你好,{username}
请点击以下链接,验证你的 Fluxer 账户邮箱地址:
{verifyUrl}
若你未创建 Fluxer 账户,请忽略此邮件。
该链接将于 24 小时后失效。
- Fluxer 团队`,
},
ipAuthorization: {
subject: '确认来自新 IP 地址的登录',
body: `你好,{username}
我们检测到你的 Fluxer 账户有来自新 IP 地址的登录尝试:
IP 地址:{ipAddress}
位置:{location}
如果这是你本人,请点击以下链接授权该 IP 地址:
{authUrl}
如果并非你本人,请立即修改密码。
该授权链接将于 30 分钟后失效。
- Fluxer 团队`,
},
accountDisabledSuspicious: {
subject: '你的 Fluxer 账户因异常活动已被暂时停用',
body: `你好,{username}
由于检测到可疑活动,你的 Fluxer 账户已被暂时停用。
{reason, select,
null {}
other {原因:{reason}
}}要恢复账户访问,你必须先重置密码:
{forgotUrl}
重置密码后,你将能够再次登录。
如果你认为这是错误操作,请联系支持团队。
- Fluxer 安全团队`,
},
accountTempBanned: {
subject: '你的 Fluxer 账户已被临时封禁',
body: `你好,{username}
你的 Fluxer 账户因违反服务条款或社区指南而被临时封禁。
封禁时长:{durationHours, plural,
=1 {1 小时}
other {# 小时}
}
封禁截止时间:{bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {原因:{reason}}
}
在封禁期间,你将无法访问你的账户。
请阅读以下内容:
- 服务条款:{termsUrl}
- 社区指南:{guidelinesUrl}
若你认为此封禁不正确或不合理,你可以使用该邮箱向 appeals@fluxer.app 提交申诉。
请清楚说明你认为决定错误的理由。我们会审核你的申诉并回复最终结果。
- Fluxer 安全团队`,
},
accountScheduledDeletion: {
subject: '你的 Fluxer 账户已被安排删除',
body: `你好,{username}
由于违反服务条款或社区指南,你的 Fluxer 账户已被安排进行永久删除。
计划删除时间:{deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {原因:{reason}}
}
这是严重的执行措施。你的账户数据将在指定日期永久删除。
建议阅读:
- 服务条款:{termsUrl}
- 社区指南:{guidelinesUrl}
申诉流程:
如果你认为该决定错误或不公平,你有 30 天时间向 appeals@fluxer.app 提交申诉。
请在申诉中包括:
- 你认为决定不正确的原因
- 任何相关证据或说明
Fluxer 安全团队成员会审核你的申诉,并可能在最终裁定前暂停删除操作。
- Fluxer 安全团队`,
},
selfDeletionScheduled: {
subject: '你的 Fluxer 账户删除已被安排',
body: `你好,{username}
很遗憾看到你选择离开!你的 Fluxer 账户已被安排删除。
计划删除时间:{deletionDate, date, full} {deletionDate, time, short}
重要提示:你可以在 {deletionDate, date, full} {deletionDate, time, short} 之前随时通过登录账户取消此删除。
离开前请注意:
隐私控制面板允许你:
- 删除你在平台上的消息
- 导出重要数据
请注意:账户删除完成后,你将无法再删除消息。如需删除,请提前处理。
如果你改变主意,只需重新登录即可取消删除。
- Fluxer 团队`,
},
inactivityWarning: {
subject: '你的 Fluxer 账户因长期未使用将被删除',
body: `你好,{username}
我们注意到你已有超过两年未登录你的 Fluxer 账户。
上次登录时间:{lastActiveDate, date, full} {lastActiveDate, time, short}
根据我们的数据保留政策,长期未使用的账户会被自动安排删除。
计划删除时间:{deletionDate, date, full} {deletionDate, time, short}
如何保留你的账户:
在删除日期前登录 {loginUrl} 即可取消此自动删除,无需其他操作。
若你不登录:
- 你的账户及所有数据将被永久删除
- 你的消息将被匿名化(显示为“已删除用户”)
- 此操作不可撤销
想提前删除你的消息?
你可以登录后在隐私控制面板中进行操作。
期待你回到 Fluxer
- Fluxer 团队`,
},
harvestCompleted: {
subject: '你的 Fluxer 数据导出已准备好',
body: `你好,{username}
你的数据导出已经完成,可以下载了!
导出内容摘要:
- 消息总数:{totalMessages, number}
- 文件大小:{fileSizeMB} MB
- 格式:包含 JSON 文件的 ZIP 压缩包
下载链接:{downloadUrl}
重要提示:该链接将于 {expiresAt, date, full} {expiresAt, time, short} 失效。
包含内容:
- 所有按频道组织的消息
- 频道元数据
- 你的用户资料和账户信息
- Guild 成员关系与设置
- 身份验证会话与安全信息
数据以 JSON 格式提供,便于分析。
如有疑问,请联系 support@fluxer.app
- Fluxer 团队`,
},
unbanNotification: {
subject: '你的 Fluxer 账户封禁已解除',
body: `你好,{username}
好消息!你的 Fluxer 账户封禁已被解除。
原因:{reason}
你现在可以重新登录继续使用 Fluxer。
- Fluxer 安全团队`,
},
scheduledDeletionNotification: {
subject: '你的 Fluxer 账户已被安排删除',
body: `你好,{username}
你的 Fluxer 账户已被安排永久删除。
删除时间:{deletionDate, date, full} {deletionDate, time, short}
原因:{reason}
这是严肃的措施。你的账户数据将被永久删除。
若你认为这是错误的决定,你可以发送申诉至 appeals@fluxer.app
- Fluxer 安全团队`,
},
giftChargebackNotification: {
subject: '你的 Fluxer Premium 礼物已被撤销',
body: `你好,{username}
我们通知你,你所兑换的 Fluxer Premium 礼物因原购买者发起支付争议chargeback而被撤销。
你的 Premium 权益已被移除,因为付款已被撤回。
如有疑问,请联系 support@fluxer.app
- Fluxer 团队`,
},
reportResolved: {
subject: '你的 Fluxer 举报已处理完毕',
body: `你好,{username}
你的举报ID{reportId})已由安全团队处理。
安全团队回复:
{publicComment}
感谢你为 Fluxer 的社区安全作出的贡献。
如你对处理结果有疑问,请联系 safety@fluxer.app
- Fluxer 安全团队`,
},
dsaReportVerification: {
subject: '验证你的邮箱以提交 DSA 举报',
body: `你好:
请使用以下验证码在 Fluxer 上提交数字服务法案Digital Services Act举报
{code}
此验证码将于 {expiresAt, date, full} {expiresAt, time, short} 失效。
如果这不是你本人操作,请忽略此邮件。
- Fluxer 安全团队`,
},
registrationApproved: {
subject: '你的 Fluxer 注册已获批准',
body: `你好,{username}
好消息!你的 Fluxer 注册已获批准。
你现在可以通过以下链接进入 Fluxer
{channelsUrl}
欢迎加入 Fluxer 社区!
- Fluxer 团队`,
},
emailChangeRevert: {
subject: '你的 Fluxer 邮箱已被更改',
body: `你好,{username}
你的 Fluxer 帐户邮箱已更改为 {newEmail}。
如果是你本人操作,则无需处理。若非本人,请通过以下链接撤销并保护你的帐户:
{revertUrl}
这将恢复你之前的邮箱,登出所有会话,移除绑定的手机号,停用 MFA并要求设置新密码。
- Fluxer 安全团队`,
},
};

View File

@@ -0,0 +1,317 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {EmailTranslations} from '../types';
export const zhTW: EmailTranslations = {
passwordReset: {
subject: '重設你的 Fluxer 密碼',
body: `你好,{username}
你已提出重設 Fluxer 帳號密碼的請求。請點擊以下連結設定新密碼:
{resetUrl}
如果這不是你本人的操作,請忽略此郵件。
此連結將於 1 小時後失效。
— Fluxer 團隊`,
},
emailVerification: {
subject: '驗證你的 Fluxer 電子郵件地址',
body: `你好,{username}
請點擊以下連結,以驗證你在 Fluxer 帳號所使用的電子郵件地址:
{verifyUrl}
若你未曾註冊 Fluxer 帳號,請忽略此郵件。
此連結將於 24 小時後失效。
— Fluxer 團隊`,
},
ipAuthorization: {
subject: '確認從新 IP 位址的登入請求',
body: `你好,{username}
我們偵測到你的 Fluxer 帳號有來自新的 IP 位址的登入嘗試:
IP 位址:{ipAddress}
位置:{location}
如果這是你本人,請點擊以下連結授權此 IP 位址:
{authUrl}
如果這不是你,請立即變更密碼。
此授權連結將於 30 分鐘後失效。
— Fluxer 團隊`,
},
accountDisabledSuspicious: {
subject: '你的 Fluxer 帳號因可疑活動已被暫時停用',
body: `你好,{username}
由於偵測到可疑活動,你的 Fluxer 帳號已被暫時停用。
{reason, select,
null {}
other {原因:{reason}
}}要重新取得帳號存取權,你必須重設密碼:
{forgotUrl}
完成密碼重設後,你將能重新登入。
如果你認為這是錯誤的處置,請聯繫我們的支援團隊。
— Fluxer 安全團隊`,
},
accountTempBanned: {
subject: '你的 Fluxer 帳號已被暫時停權',
body: `你好,{username}
你的 Fluxer 帳號因違反服務條款或社群指南而遭到暫時停權。
停權時長:{durationHours, plural,
=1 {1 小時}
other {# 小時}
}
停權結束時間:{bannedUntil, date, full} {bannedUntil, time, short}
{reason, select,
null {}
other {原因:{reason}}
}
在停權期間,你將無法存取帳號。
請務必閱讀:
- 服務條款:{termsUrl}
- 社群指南:{guidelinesUrl}
若你認為此處置不正確或不公平,可以使用此電子郵件地址向 appeals@fluxer.app 提出申訴。
請清楚說明你認為決定錯誤的原因。我們會審查你的申訴並回覆結果。
— Fluxer 安全團隊`,
},
accountScheduledDeletion: {
subject: '你的 Fluxer 帳號已排程刪除',
body: `你好,{username}
由於違反服務條款或社群指南,你的 Fluxer 帳號已被排程永久刪除。
排程刪除時間:{deletionDate, date, full} {deletionDate, time, short}
{reason, select,
null {}
other {原因:{reason}}
}
這是嚴重的措施。所有帳號資料將在指定日期永久刪除。
請參考以下內容:
- 服務條款:{termsUrl}
- 社群指南:{guidelinesUrl}
申訴流程:
若你認為此決定有誤或不公平,你可在 30 天內向 appeals@fluxer.app 提出申訴。
你的申訴應包含:
- 為何你認為決定錯誤或不公
- 任何可佐證的相關資訊
Fluxer 安全團隊將審核申訴並可能暫停刪除作業,直至做出最終裁決。
— Fluxer 安全團隊`,
},
selfDeletionScheduled: {
subject: '你的 Fluxer 帳號刪除已預定',
body: `你好,{username}
很遺憾看到你選擇離開!你的 Fluxer 帳號刪除作業已排程完成。
預定刪除時間:{deletionDate, date, full} {deletionDate, time, short}
重要提示:在 {deletionDate, date, full} {deletionDate, time, short} 之前,你可以隨時重新登入以取消刪除。
離開前請注意:
隱私控制面板可讓你:
- 刪除你在平台上的訊息
- 匯出資料以備份保存
注意:帳號刪除完成後,你將無法刪除訊息。若需刪除請提前處理。
若改變心意,只要重新登入即可取消刪除。
— Fluxer 團隊`,
},
inactivityWarning: {
subject: '你的 Fluxer 帳號因長期未使用將被刪除',
body: `你好,{username}
我們注意到你已有超過兩年未登入 Fluxer 帳號。
上次登入時間:{lastActiveDate, date, full} {lastActiveDate, time, short}
依據我們的資料保存政策,長期未使用的帳號會自動排程刪除。
預定刪除時間:{deletionDate, date, full} {deletionDate, time, short}
如何保留你的帳號:
只需在刪除日期前於 {loginUrl} 登入即可取消刪除。
如果你未登入:
- 帳號及所有資料將被永久刪除
- 你的訊息將被匿名化(顯示為「已刪除使用者」)
- 此操作無法復原
想先刪除你的訊息嗎?
登入後可於隱私控制面板操作。
期待你再次回到 Fluxer
— Fluxer 團隊`,
},
harvestCompleted: {
subject: '你的 Fluxer 資料匯出已準備完成',
body: `你好,{username}
你的資料匯出已完成,可立即下載!
匯出摘要:
- 訊息總數:{totalMessages, number}
- 檔案大小:{fileSizeMB} MB
- 格式:包含 JSON 檔案的 ZIP 壓縮包
下載你的資料:{downloadUrl}
重要:此下載連結將於 {expiresAt, date, full} {expiresAt, time, short} 到期。
匯出內容包括:
- 所有訊息,依頻道分類
- 頻道後設資料
- 你的使用者資料與帳號資訊
- Guild 加入與設定
- 驗證工作階段與安全資訊
資料以 JSON 格式提供,方便後續分析。
若你有任何疑問,請聯繫 support@fluxer.app
— Fluxer 團隊`,
},
unbanNotification: {
subject: '你的 Fluxer 帳號停權已解除',
body: `你好,{username}
好消息!你的 Fluxer 帳號停權已被解除。
原因:{reason}
你現在可以重新登入並繼續使用 Fluxer。
— Fluxer 安全團隊`,
},
scheduledDeletionNotification: {
subject: '你的 Fluxer 帳號已排程刪除',
body: `你好,{username}
你的 Fluxer 帳號已排程進行永久刪除。
刪除日期:{deletionDate, date, full} {deletionDate, time, short}
原因:{reason}
這是嚴重的操作,你的帳號資料將永久刪除。
若你認為此決定有誤,可寄信至 appeals@fluxer.app 提出申訴。
— Fluxer 安全團隊`,
},
giftChargebackNotification: {
subject: '你的 Fluxer Premium 禮物已被撤銷',
body: `你好,{username}
我們通知你,你兌換的 Fluxer Premium 禮物因原購買者提出付款爭議chargeback而被撤銷。
你的 Premium 權益已從帳號中移除,因付款已被退回。
若有疑問,請聯繫 support@fluxer.app
— Fluxer 團隊`,
},
reportResolved: {
subject: '你的 Fluxer 檢舉已處理完成',
body: `你好,{username}
你的檢舉ID{reportId})已由 Fluxer 安全團隊審查完成。
安全團隊回覆:
{publicComment}
感謝你協助維護 Fluxer 社群的安全。
若你對此結果有疑慮,請聯繫 safety@fluxer.app
— Fluxer 安全團隊`,
},
dsaReportVerification: {
subject: '驗證你的電子郵件以提交 DSA 檢舉',
body: `你好:
請使用以下驗證碼提交你在 Fluxer 的數位服務法檢舉:
{code}
此驗證碼將於 {expiresAt, date, full} {expiresAt, time, short} 失效。
若非你本人提出此請求,請忽略此郵件。
— Fluxer 安全團隊`,
},
registrationApproved: {
subject: '你的 Fluxer 註冊已獲批准',
body: `你好,{username}
好消息!你的 Fluxer 註冊已獲批准。
你現在可以透過以下連結登入 Fluxer
{channelsUrl}
歡迎加入 Fluxer 社群!
— Fluxer 團隊`,
},
emailChangeRevert: {
subject: '你的 Fluxer 電子郵件已被更改',
body: `你好,{username}
你的 Fluxer 帳戶電子郵件已變更為 {newEmail}。
若此變更為你本人操作,則無需處理。若非你本人,請透過以下連結撤銷並保護你的帳戶:
{revertUrl}
這將恢復你先前的電子郵件、登出所有工作階段、移除綁定的電話號碼、停用 MFA並要求設定新密碼。
- Fluxer 安全團隊`,
},
};

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export type EmailTemplateKey =
| 'passwordReset'
| 'emailVerification'
| 'emailChangeOriginal'
| 'emailChangeNew'
| 'emailChangeRevert'
| 'ipAuthorization'
| 'accountDisabledSuspicious'
| 'accountTempBanned'
| 'accountScheduledDeletion'
| 'selfDeletionScheduled'
| 'inactivityWarning'
| 'harvestCompleted'
| 'unbanNotification'
| 'scheduledDeletionNotification'
| 'giftChargebackNotification'
| 'reportResolved'
| 'dsaReportVerification'
| 'registrationApproved';
export interface EmailTemplateVariables {
passwordReset: {
username: string;
resetUrl: string;
};
emailVerification: {
username: string;
verifyUrl: string;
};
emailChangeOriginal: {
username: string;
code: string;
expiresAt: Date;
};
emailChangeNew: {
username: string;
code: string;
expiresAt: Date;
};
emailChangeRevert: {
username: string;
newEmail: string;
revertUrl: string;
};
ipAuthorization: {
username: string;
authUrl: string;
ipAddress: string;
location: string;
};
accountDisabledSuspicious: {
username: string;
reason: string | null;
forgotUrl: string;
};
accountTempBanned: {
username: string;
reason: string | null;
durationHours: number;
bannedUntil: Date;
termsUrl: string;
guidelinesUrl: string;
};
accountScheduledDeletion: {
username: string;
reason: string | null;
deletionDate: Date;
termsUrl: string;
guidelinesUrl: string;
};
selfDeletionScheduled: {
username: string;
deletionDate: Date;
};
inactivityWarning: {
username: string;
deletionDate: Date;
lastActiveDate: Date;
loginUrl: string;
};
harvestCompleted: {
username: string;
downloadUrl: string;
totalMessages: number;
fileSizeMB: number;
expiresAt: Date;
};
unbanNotification: {
username: string;
reason: string;
};
scheduledDeletionNotification: {
username: string;
deletionDate: Date;
reason: string;
};
giftChargebackNotification: {
username: string;
};
reportResolved: {
username: string;
reportId: string;
publicComment: string;
};
dsaReportVerification: {
code: string;
expiresAt: Date;
};
registrationApproved: {
username: string;
channelsUrl: string;
};
}
export interface EmailTemplate {
subject: string;
body: string;
}
export type EmailTranslations = Partial<Record<EmailTemplateKey, EmailTemplate>>;