Compare commits

...
Author SHA1 Message Date
Hampus 2d8d130be1 feat(api): allow routing all email through fallback (#3509) 2026-10-17 04:16:08 +02:00
Hampus d564e01518 chore(deps): bump zip to 9.0.4 (#3508) 2026-10-17 02:54:04 +02:00
8 changed files with 48 additions and 13 deletions

No files matched your search

Generated
+16 -3
View File
@@ -2986,6 +2986,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nkeys"
version = "0.4.5"
@@ -4613,7 +4625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -5763,14 +5775,15 @@ dependencies = [
[[package]]
name = "zip"
version = "8.6.0"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"nix",
"typed-path",
"zopfli",
]
@@ -30,6 +30,16 @@ describe('DomainRoutedEmailProvider', () => {
expect(result).toBe(route === 'primary');
});
it('sends every address through the fallback provider when the domains are *', async () => {
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
const provider = new DomainRoutedEmailProvider(primary, fallback, ['*']);
await provider.sendEmail(message('[email protected]'));
await provider.sendEmail(message('[email protected]'));
expect(fallback.sendEmail).toHaveBeenCalledTimes(2);
expect(primary.sendEmail).not.toHaveBeenCalled();
});
it('never reaches either provider for a bounced address', async () => {
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
@@ -21,7 +21,7 @@ export class DomainRoutedEmailProvider implements IEmailProvider {
.slice(message.to.lastIndexOf('@') + 1)
.trim()
.toLowerCase();
const useFallback = this.fallbackDomains.has(domain);
const useFallback = this.fallbackDomains.has('*') || this.fallbackDomains.has(domain);
logger.debug({domain, provider: useFallback ? 'fallback' : 'primary'}, 'Routing email');
return (useFallback ? this.fallback : this.primary).sendEmail(message);
}
@@ -843,7 +843,7 @@ A second SMTP provider can take the mail for chosen recipient domains while ever
#### `FLUXER_EMAIL_FALLBACK_DOMAINS`
Default empty. The recipient domains that go through the fallback provider, comma separated, such as `gmail.com,googlemail.com`. Matching ignores case and takes the part of the address after the last `@`. Only an exact match counts, so `gmail.com` leaves `mail.gmail.com` on the main provider. An entry that is not a bare domain fails startup. `.env.example` lists it commented out.
Default empty. The recipient domains that go through the fallback provider, comma separated, such as `gmail.com,googlemail.com`. Matching ignores case and takes the part of the address after the last `@`. Only an exact match counts, so `gmail.com` leaves `mail.gmail.com` on the main provider. Set it to `*` alone to send every email through the fallback provider. An entry that is not a bare domain or `*` fails startup. `.env.example` lists it commented out.
#### `FLUXER_EMAIL_FALLBACK_SMTP_HOST`
+4 -1
View File
@@ -379,8 +379,11 @@ function normalizeEmailFallbackConfig(config: MasterConfig): void {
assertIntegerInRange(port, 'FLUXER_EMAIL_FALLBACK_SMTP_PORT', 1, 65_535);
validateSmtpHeaders(smtp.headers, 'FLUXER_EMAIL_FALLBACK_SMTP_HEADERS');
const normalizedDomains = domains.map((domain) => domain.trim().toLowerCase());
if (normalizedDomains.includes('*') && new Set(normalizedDomains).size > 1) {
throw new Error('FLUXER_EMAIL_FALLBACK_DOMAINS must be either * or a list of domains');
}
for (const domain of normalizedDomains) {
if (!EMAIL_DOMAIN_PATTERN.test(domain)) {
if (domain !== '*' && !EMAIL_DOMAIN_PATTERN.test(domain)) {
throw new Error(
`FLUXER_EMAIL_FALLBACK_DOMAINS entry ${JSON.stringify(domain)} must be a domain such as gmail.com`,
);
@@ -540,6 +540,17 @@ describe('ConfigLoader', () => {
},
);
test('loads * as the email fallback domains', async () => {
stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_DOMAINS: ' * '});
const config = await loadConfig();
expect(config.integrations.email.fallback?.domains).toEqual(['*']);
});
test('rejects * mixed with email fallback domains', async () => {
stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_DOMAINS: '*,gmail.com'});
await expect(loadConfig()).rejects.toThrow('FLUXER_EMAIL_FALLBACK_DOMAINS must be either * or a list of domains');
});
test('loads the email fallback headers', async () => {
stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: '{"X-Tracking":"{\\"enable\\":0}"}'});
const config = await loadConfig();
+1 -1
View File
@@ -22,4 +22,4 @@ tar = "0.4.46"
tempfile = "3.27.0"
tokio = { version = "1.53.1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
walkdir = "2.5.0"
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
zip = { version = "9.0.4", default-features = false, features = ["deflate"] }
+4 -6
View File
@@ -3358,10 +3358,8 @@ fn velopack_path_lengths(nupkg: &Path, prefix: &Path) -> Result<Vec<ArchivePathL
let mut entries = Vec::new();
for index in 0..archive.len() {
let entry = archive.by_index(index)?;
let normalized = entry
.name()
.trim_start_matches(['/', '\\'])
.replace('\\', "/");
let name = entry.name()?.into_owned();
let normalized = name.trim_start_matches(['/', '\\']).replace('\\', "/");
let full = if normalized.is_empty() {
prefix.to_path_buf()
} else {
@@ -3369,7 +3367,7 @@ fn velopack_path_lengths(nupkg: &Path, prefix: &Path) -> Result<Vec<ArchivePathL
};
entries.push(ArchivePathLength {
length: full.to_string_lossy().len(),
name: entry.name().to_string(),
name,
});
}
entries.sort_by(|a, b| b.length.cmp(&a.length).then_with(|| a.name.cmp(&b.name)));
@@ -4291,7 +4289,7 @@ fn extract_zip_safely(archive_path: &Path, destination: &Path) -> Result<()> {
let relative = entry.enclosed_name().ok_or_else(|| {
anyhow!(
"Refusing to extract unsafe archive path '{}' from {}",
entry.name(),
entry.name().unwrap_or_default(),
archive_path.display()
)
})?;