mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 11:42:32 +09:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
811341bc2f | ||
|
|
98fa41dcf0 |
@@ -309,6 +309,7 @@ FLUXER_EMAIL_ENABLED=false
|
||||
FLUXER_EMAIL_PROVIDER=none
|
||||
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
||||
FLUXER_EMAIL_FROM_NAME=Fluxer
|
||||
#[email protected]
|
||||
FLUXER_EMAIL_APP_BASE_URL=
|
||||
FLUXER_EMAIL_SMTP_HOST=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
|
||||
@@ -113,6 +113,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-}
|
||||
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
|
||||
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-}
|
||||
FLUXER_EMAIL_REPLY_TO_EMAIL: ${FLUXER_EMAIL_REPLY_TO_EMAIL:-}
|
||||
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
|
||||
FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-}
|
||||
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface EmailConfig {
|
||||
enabled: boolean;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
replyTo?: string | null;
|
||||
appBaseUrl: string;
|
||||
marketingBaseUrl: string;
|
||||
}
|
||||
@@ -14,6 +15,7 @@ export interface EmailMessage {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EmailI18nService} from '@pkgs/email/src/EmailI18nService';
|
||||
import type {EmailConfig, EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@pkgs/email/src/EmailService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const CONFIG: EmailConfig = {
|
||||
enabled: true,
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Fluxer',
|
||||
appBaseUrl: 'https://example.com',
|
||||
marketingBaseUrl: 'https://example.com',
|
||||
};
|
||||
|
||||
async function sendWith(config: EmailConfig): Promise<EmailMessage> {
|
||||
const sent: Array<EmailMessage> = [];
|
||||
const provider: IEmailProvider = {
|
||||
sendEmail: async (message) => {
|
||||
sent.push(message);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = new EmailService(config, new EmailI18nService(), provider);
|
||||
await expect(service.sendRegistrationApprovedEmail('[email protected]', 'testuser', 'en-US')).resolves.toBe(true);
|
||||
expect(sent).toHaveLength(1);
|
||||
return sent[0];
|
||||
}
|
||||
|
||||
describe('EmailService reply-to', () => {
|
||||
it('sets the configured reply-to address on every message', async () => {
|
||||
const message = await sendWith({...CONFIG, replyTo: '[email protected]'});
|
||||
expect(message.replyTo).toBe('[email protected]');
|
||||
expect(message.from).toEqual({email: '[email protected]', name: 'Fluxer'});
|
||||
});
|
||||
|
||||
it.each([undefined, null, ''])('omits the reply-to address when it is %j', async (replyTo) => {
|
||||
const message = await sendWith({...CONFIG, replyTo});
|
||||
expect(message).not.toHaveProperty('replyTo');
|
||||
});
|
||||
});
|
||||
@@ -375,6 +375,7 @@ export class EmailService implements IEmailService {
|
||||
return this.provider.sendEmail({
|
||||
to: email,
|
||||
from: {email: this.config.fromEmail, name: this.config.fromName},
|
||||
...(this.config.replyTo ? {replyTo: this.config.replyTo} : {}),
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()}));
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
default: {createTransport: () => ({sendMail, verify: vi.fn()})},
|
||||
}));
|
||||
|
||||
const MESSAGE = {
|
||||
to: '[email protected]',
|
||||
from: {email: '[email protected]', name: 'Fluxer'},
|
||||
subject: 'Subject',
|
||||
text: 'Body',
|
||||
};
|
||||
|
||||
function createProvider(): SmtpEmailProvider {
|
||||
return new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
|
||||
}
|
||||
|
||||
describe('SmtpEmailProvider', () => {
|
||||
beforeEach(() => {
|
||||
sendMail.mockReset();
|
||||
sendMail.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('passes the reply-to address to nodemailer', async () => {
|
||||
await expect(createProvider().sendEmail({...MESSAGE, replyTo: '[email protected]'})).resolves.toBe(true);
|
||||
expect(sendMail).toHaveBeenCalledWith({
|
||||
to: '[email protected]',
|
||||
from: 'Fluxer <[email protected]>',
|
||||
replyTo: '[email protected]',
|
||||
subject: 'Subject',
|
||||
text: 'Body',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the reply-to address when the message has none', async () => {
|
||||
await expect(createProvider().sendEmail(MESSAGE)).resolves.toBe(true);
|
||||
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('replyTo');
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
await this.transporter.sendMail({
|
||||
to: message.to,
|
||||
from: `${message.from.name} <${message.from.email}>`,
|
||||
...(message.replyTo ? {replyTo: message.replyTo} : {}),
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
});
|
||||
|
||||
@@ -248,6 +248,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
|
||||
fromEmail: master.integrations.email.from_email,
|
||||
fromName: master.integrations.email.from_name,
|
||||
replyToEmail: master.integrations.email.reply_to_email,
|
||||
appBaseUrl: resolveEmailAppBaseUrl(master),
|
||||
smtp: master.integrations.email.smtp
|
||||
? {
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface APIConfig {
|
||||
webhookSecret?: string;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
replyToEmail: string;
|
||||
appBaseUrl: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
|
||||
@@ -187,6 +187,7 @@ function createEmailServiceForConfig(
|
||||
enabled: emailConfigSource.enabled,
|
||||
fromEmail: emailConfigSource.fromEmail,
|
||||
fromName: emailConfigSource.fromName,
|
||||
replyTo: emailConfigSource.replyToEmail || null,
|
||||
appBaseUrl: emailConfigSource.appBaseUrl,
|
||||
marketingBaseUrl: Config.endpoints.marketing,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type ScreenShareCodecProfileEntry,
|
||||
type ScreenShareCodecRanking,
|
||||
} from '@app/features/voice/utils/ScreenShareCodecSelection';
|
||||
import {normaliseStreamingModeForContext} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {isH264SoftwareClamped, normaliseStreamingModeForContext} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {getProbedVideoDecoderExclusionsSync} from '@app/features/voice/utils/VideoDecoderCapabilities';
|
||||
import type {TrackPublishDefaults, TrackPublishOptions} from 'livekit-client';
|
||||
import {BackupCodecPolicy, supportsVideoCodec, type VideoCodec, type VideoEncoding} from 'livekit-client';
|
||||
@@ -410,6 +410,7 @@ export function buildScreenShareCodecProfile(): ScreenShareCodecProfile {
|
||||
h265: entry('h265'),
|
||||
av1: entry('av1'),
|
||||
},
|
||||
h264SoftwareClamped: isH264SoftwareClamped(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {VideoCodec} from 'livekit-client';
|
||||
|
||||
export const CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
|
||||
const SOFTWARE_H264_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'vp9', 'h264', 'vp8'];
|
||||
const CLAMPED_H264_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'vp9', 'vp8', 'h264'];
|
||||
const GECKO_CODEC_PREFERENCE: ReadonlyArray<VideoCodec> = ['vp8', 'h264'];
|
||||
const COMPATIBILITY_CODECS: ReadonlySet<VideoCodec> = new Set(['h264', 'vp9', 'vp8']);
|
||||
export const LAST_RESORT_VIDEO_CODEC: VideoCodec = 'vp8';
|
||||
@@ -79,6 +80,7 @@ export interface ScreenShareCodecProfile {
|
||||
browser: ScreenShareCodecBrowser;
|
||||
desktop: boolean;
|
||||
codecs: Record<VideoCodec, ScreenShareCodecProfileEntry>;
|
||||
h264SoftwareClamped?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenShareCodecRankingInput {
|
||||
@@ -94,7 +96,9 @@ export interface ScreenShareCodecRanking {
|
||||
|
||||
export function rankScreenShareCodecs(input: ScreenShareCodecRankingInput): ScreenShareCodecRanking {
|
||||
const {codecs} = input.profile;
|
||||
const isHardware = (codec: VideoCodec): boolean => codecs[codec].supported && codecs[codec].hardware;
|
||||
const h264Clamped = input.profile.h264SoftwareClamped === true;
|
||||
const isHardware = (codec: VideoCodec): boolean =>
|
||||
codecs[codec].supported && codecs[codec].hardware && !(codec === 'h264' && h264Clamped);
|
||||
const hardwareAvailable = CODEC_PREFERENCE.some((codec) => isHardware(codec));
|
||||
const pin = input.pin !== 'auto' && codecs[input.pin].allowed && codecs[input.pin].supported ? input.pin : null;
|
||||
const baselineOnly =
|
||||
@@ -108,9 +112,11 @@ export function rankScreenShareCodecs(input: ScreenShareCodecRankingInput): Scre
|
||||
const preference =
|
||||
input.profile.browser === 'firefox'
|
||||
? GECKO_CODEC_PREFERENCE
|
||||
: isHardware('h264')
|
||||
? CODEC_PREFERENCE
|
||||
: SOFTWARE_H264_CODEC_PREFERENCE;
|
||||
: h264Clamped
|
||||
? CLAMPED_H264_CODEC_PREFERENCE
|
||||
: isHardware('h264')
|
||||
? CODEC_PREFERENCE
|
||||
: SOFTWARE_H264_CODEC_PREFERENCE;
|
||||
const survivors = preference.filter(survives);
|
||||
const ranked =
|
||||
input.encoderModeSetting === 'software'
|
||||
|
||||
@@ -330,11 +330,14 @@ export function resolveScreenShareDegradationPreference(
|
||||
return 'maintain-framerate';
|
||||
}
|
||||
|
||||
function shouldClampToSoftwareH264(codec: VideoCodec | undefined): boolean {
|
||||
if (codec !== 'h264') return false;
|
||||
export function isH264SoftwareClamped(): boolean {
|
||||
return getH264HardwareProfilesSync()?.profiles.size === 0;
|
||||
}
|
||||
|
||||
function shouldClampToSoftwareH264(codec: VideoCodec | undefined): boolean {
|
||||
return codec === 'h264' && isH264SoftwareClamped();
|
||||
}
|
||||
|
||||
function clampToSoftwareH264Budget(quality: {
|
||||
mode: StreamingMode;
|
||||
resolution: ScreenshareResolution;
|
||||
|
||||
@@ -730,6 +730,10 @@ The same conditions turn on a DNS check at registration. The check runs when ema
|
||||
|
||||
`.env.example` `Fluxer`. The sender name. Default `Fluxer`, which an empty value also gives. Compose forwards it from `.env`.
|
||||
|
||||
#### `FLUXER_EMAIL_REPLY_TO_EMAIL`
|
||||
|
||||
Default empty. The address replies go to, sent as the `Reply-To` header on every email. An empty value sends no `Reply-To` header. Must be a single bare address such as `[email protected]`, or the API fails at boot. The admin dashboard has no field for it. Compose forwards it from `.env`, and `.env.example` lists it commented out.
|
||||
|
||||
#### `FLUXER_EMAIL_APP_BASE_URL`
|
||||
|
||||
`.env.example` empty. The base URL used in links. Must be http or https with no username, password, query, or fragment, or the API fails at boot. Falls back to the app endpoint. Compose forwards it from `.env`.
|
||||
|
||||
@@ -163,6 +163,7 @@ function defaultConfig(): MasterConfig {
|
||||
provider: 'none',
|
||||
from_email: '',
|
||||
from_name: 'Fluxer',
|
||||
reply_to_email: '',
|
||||
app_base_url: '',
|
||||
},
|
||||
voice: {
|
||||
@@ -292,6 +293,12 @@ function requireString(value: string | undefined, envName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateReplyToEmail(value: string): void {
|
||||
if (value !== '' && !/^[^\s@<>,;"]+@[^\s@<>,;"]+$/.test(value)) {
|
||||
throw new Error('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address such as [email protected]');
|
||||
}
|
||||
}
|
||||
|
||||
function validateUploadRelaySecret(value: string): void {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
@@ -566,6 +573,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
|
||||
validateStorageChangeFeedConfig(config);
|
||||
validateCachePurgeConfig(config);
|
||||
validateStoreBillingConfig(config);
|
||||
validateReplyToEmail(config.integrations.email.reply_to_email);
|
||||
normalizeAppOriginAliases(config);
|
||||
assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000);
|
||||
assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000);
|
||||
|
||||
@@ -169,6 +169,7 @@ export interface MasterConfig {
|
||||
provider: 'smtp' | 'none';
|
||||
from_email: string;
|
||||
from_name: string;
|
||||
reply_to_email: string;
|
||||
app_base_url: string;
|
||||
webhook_secret?: string;
|
||||
smtp?: {
|
||||
|
||||
@@ -290,6 +290,30 @@ describe('ConfigLoader', () => {
|
||||
expect(config.services.api.storage_change_feed?.skip_buckets).toBeUndefined();
|
||||
});
|
||||
|
||||
test('reads the email reply-to address', async () => {
|
||||
stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: '[email protected]'});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.integrations.email.reply_to_email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
test('leaves the email reply-to address empty when unset or blank', async () => {
|
||||
stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: ' '});
|
||||
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.integrations.email.reply_to_email).toBe('');
|
||||
});
|
||||
|
||||
test.each(['support', 'Support <[email protected]>', '[email protected],[email protected]', ' [email protected]'])(
|
||||
'rejects %j as the email reply-to address',
|
||||
async (value) => {
|
||||
stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: value});
|
||||
await expect(loadConfig()).rejects.toThrow('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address');
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps explicit passkey relying party values', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
||||
FLUXER_S3_FORCE_PATH_STYLE: 'true',
|
||||
FLUXER_AUTH_BLUESKY_KEYS: '[{"kid":"key-1","private_key_path":"/etc/fluxer/keys/bluesky.pem"}]',
|
||||
FLUXER_STRIPE_PRICE_MONTHLY_USD: 'price_monthly_usd',
|
||||
FLUXER_EMAIL_REPLY_TO_EMAIL: '[email protected]',
|
||||
});
|
||||
|
||||
expect(overrides).toMatchObject({
|
||||
@@ -62,7 +63,10 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
||||
bluesky: {keys: [{kid: 'key-1', private_key_path: '/etc/fluxer/keys/bluesky.pem'}]},
|
||||
},
|
||||
s3: {force_path_style: true},
|
||||
integrations: {stripe: {prices: {monthly_usd: 'price_monthly_usd'}}},
|
||||
integrations: {
|
||||
stripe: {prices: {monthly_usd: 'price_monthly_usd'}},
|
||||
email: {reply_to_email: '[email protected]'},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +81,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: blank,
|
||||
FLUXER_AUTH_BLUESKY_KEYS: blank,
|
||||
FLUXER_EMAIL_FROM_NAME: blank,
|
||||
FLUXER_EMAIL_REPLY_TO_EMAIL: blank,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
@@ -148,6 +148,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_EMAIL_PROVIDER: {path: ['integrations', 'email', 'provider']},
|
||||
FLUXER_EMAIL_FROM_EMAIL: {path: ['integrations', 'email', 'from_email']},
|
||||
FLUXER_EMAIL_FROM_NAME: {path: ['integrations', 'email', 'from_name']},
|
||||
FLUXER_EMAIL_REPLY_TO_EMAIL: {path: ['integrations', 'email', 'reply_to_email']},
|
||||
FLUXER_EMAIL_APP_BASE_URL: {path: ['integrations', 'email', 'app_base_url']},
|
||||
FLUXER_EMAIL_WEBHOOK_SECRET: {path: ['integrations', 'email', 'webhook_secret']},
|
||||
FLUXER_EMAIL_SMTP_HOST: {path: ['integrations', 'email', 'smtp', 'host']},
|
||||
|
||||
Reference in New Issue
Block a user