import { beforeEach, describe, expect, it, vi } from "vitest";
import { SieveService } from "../src/services/SieveService.js";
import type {
SieveCapabilities,
SieveConnection,
SieveScript,
} from "../src/types/sieve.types.js";
// Testable subclass to access private methods
class TestableSieveService extends SieveService {
public testParseScriptList(data: string): SieveScript[] {
return (this as any).parseScriptList(data);
}
public testParseCapabilities(data: string): SieveCapabilities {
return (this as any).parseCapabilities(data);
}
}
// Mock the logger
vi.mock("../src/services/Logger.js", () => ({
createLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
})),
}));
describe("SieveService", () => {
let sieveService: SieveService;
let mockSocket: unknown;
const mockConfig: SieveConnection = {
host: "imap.mailbox.org",
port: 4190,
secure: false,
user: "test@mailbox.org",
password: "test-password",
};
beforeEach(() => {
// Create mock socket
mockSocket = {
connect: vi.fn(),
write: vi.fn(),
destroy: vi.fn(),
on: vi.fn(),
removeListener: vi.fn(),
};
sieveService = new SieveService(mockConfig);
});
describe("connection management", () => {
it("should start in disconnected state", () => {
expect(sieveService.isConnected()).toBe(false);
expect(sieveService.isAuthenticated()).toBe(false);
});
it("should expose server capabilities getter", () => {
const capabilities = sieveService.getServerCapabilities();
expect(capabilities).toBeNull();
});
});
describe("script validation", () => {
it("should accept valid Sieve script syntax", async () => {
const validScript = `require ["fileinto"];
if header :contains "From" "example.com" {
fileinto "Example";
stop;
}`;
// Note: This test validates the script format, not actual server validation
// Real validation happens when checkScript() calls the server
expect(validScript).toBeTruthy();
expect(validScript).toContain("require");
expect(validScript).toContain("fileinto");
});
it("should handle complex Sieve scripts", () => {
const complexScript = `require ["fileinto", "envelope", "regex"];
# Important documents
if anyof (
header :contains "From" "docusign.net",
header :contains "Subject" ["Contract", "Agreement"]
) {
fileinto "Important";
stop;
}
# Newsletter filtering
if header :contains "From" [
"newsletter@example.com",
"news@company.com"
] {
fileinto "Newsletter";
stop;
}`;
expect(complexScript).toBeTruthy();
expect(complexScript).toContain("anyof");
expect(complexScript).toContain("fileinto");
});
});
describe("parseScriptList", () => {
it("should parse script list response format", () => {
// This is a white-box test of the internal parsing logic
const mockResponse = `"script1" ACTIVE
"script2"
OK Listscripts completed.`;
// Expected output: [
// { name: "script1", content: "", active: true },
// { name: "script2", content: "", active: false }
// ]
// Since parseScriptList is private, we test it indirectly through the public API
// This test validates our understanding of the response format
expect(mockResponse).toContain("ACTIVE");
expect(mockResponse).toContain("OK");
});
});
describe("parseCapabilities", () => {
it("should understand capability response format", () => {
const mockCapabilities = `"IMPLEMENTATION" "Dovecot Pigeonhole"
"VERSION" "1.0"
"SASL" "PLAIN LOGIN"
"SIEVE" "fileinto envelope body"
"STARTTLS"
OK Capability completed.`;
// Expected parsing:
// - implementation: "Dovecot Pigeonhole"
// - version: "1.0"
// - saslMechanisms: ["PLAIN", "LOGIN"]
// - sieveExtensions: ["fileinto", "envelope", "body", "STARTTLS"]
expect(mockCapabilities).toContain("IMPLEMENTATION");
expect(mockCapabilities).toContain("SASL");
expect(mockCapabilities).toContain("SIEVE");
});
});
describe("error handling", () => {
it("should require connection before authentication", async () => {
await expect(sieveService.authenticate()).rejects.toThrow(
"Not connected to server",
);
});
it("should require authentication before operations", async () => {
// ensureAuthenticated should connect and authenticate if needed
// But without a real connection, operations should fail gracefully
await expect(sieveService.listScripts()).rejects.toThrow();
});
});
describe("Sieve script examples", () => {
it("should validate newsletter filter script format", () => {
const newsletterFilter = `require ["fileinto"];
if header :contains "From" [
"correctiv.org",
"krautreporter.de",
"substack.com"
] {
fileinto "Newsletter";
stop;
}`;
expect(newsletterFilter).toContain('require ["fileinto"]');
expect(newsletterFilter).toContain("header :contains");
expect(newsletterFilter).toContain("stop");
});
it("should validate transactional email filter format", () => {
const transactionalFilter = `require ["fileinto"];
if anyof (
header :contains "From" ["paypal.de", "stripe.com"],
header :contains "Subject" ["Order", "Receipt", "Invoice"]
) {
fileinto "Transactional";
stop;
}`;
expect(transactionalFilter).toContain("anyof");
expect(transactionalFilter).toContain("fileinto");
expect(transactionalFilter).toContain("Transactional");
});
it("should validate banking filter format", () => {
const bankingFilter = `require ["fileinto"];
if header :contains "From" [
"deutsche-bank.de",
"revolut.com",
"tomorrow.one"
] {
fileinto "Banking";
stop;
}`;
expect(bankingFilter).toContain("Banking");
expect(bankingFilter).toContain("header :contains");
});
});
describe("connection state management", () => {
it("should track connection state", () => {
expect(sieveService.isConnected()).toBe(false);
// After successful connection, isConnected() should return true
// This would be tested in real integration tests
});
it("should track authentication state", () => {
expect(sieveService.isAuthenticated()).toBe(false);
// After successful authentication, isAuthenticated() should return true
// This would be tested in real integration tests
});
it("should reset state on disconnect", async () => {
await sieveService.disconnect();
expect(sieveService.isConnected()).toBe(false);
expect(sieveService.isAuthenticated()).toBe(false);
expect(sieveService.getServerCapabilities()).toBeNull();
});
});
describe("ManageSieve protocol", () => {
it("should follow RFC 5804 protocol basics", () => {
// ManageSieve protocol uses:
// - CAPABILITY command to get server capabilities
// - AUTHENTICATE for authentication (supports PLAIN, LOGIN, etc.)
// - LISTSCRIPTS to list available scripts
// - GETSCRIPT to retrieve script content
// - PUTSCRIPT to upload/update scripts
// - SETACTIVE to activate a script
// - DELETESCRIPT to delete scripts
// - CHECKSCRIPT to validate script syntax
// - LOGOUT to end session
const commands = [
"CAPABILITY",
"AUTHENTICATE",
"LISTSCRIPTS",
"GETSCRIPT",
"PUTSCRIPT",
"SETACTIVE",
"DELETESCRIPT",
"CHECKSCRIPT",
"LOGOUT",
];
// Verify we understand the protocol command set
expect(commands).toHaveLength(9);
expect(commands).toContain("CAPABILITY");
expect(commands).toContain("AUTHENTICATE");
});
it("should use PLAIN SASL mechanism format", () => {
const username = "test@mailbox.org";
const password = "test-password";
const authString = `\0${username}\0${password}`;
const authBase64 = Buffer.from(authString).toString("base64");
// PLAIN SASL format: \0username\0password (base64 encoded)
expect(authBase64).toBeTruthy();
expect(authString).toContain("\0");
});
});
describe("edge cases", () => {
it("should handle multiple connects gracefully", async () => {
// Second connect should be a no-op if already connected
// This prevents connection leaks
});
it("should handle disconnect when not connected", async () => {
await expect(sieveService.disconnect()).resolves.not.toThrow();
});
it("should handle empty script list", () => {
const emptyResponse = "OK Listscripts completed.";
expect(emptyResponse).toContain("OK");
});
it("should handle scripts with special characters", () => {
const specialScript = `require ["fileinto"];
# German umlauts and special characters
if header :contains "From" "müller@example.de" {
fileinto "Spëcial";
stop;
}`;
expect(specialScript).toContain("müller");
expect(specialScript).toContain("Spëcial");
});
});
describe("parseScriptList - branch coverage", () => {
let testableService: TestableSieveService;
beforeEach(() => {
testableService = new TestableSieveService(mockConfig);
});
it.each([
[
"quoted script with ACTIVE",
'"script1" ACTIVE\r\nOK',
[{ name: "script1", content: "", active: true }],
],
[
"quoted script without ACTIVE",
'"script2"\r\nOK',
[{ name: "script2", content: "", active: false }],
],
[
"multiple quoted scripts",
'"script1" ACTIVE\r\n"script2"\r\n"script3" ACTIVE\r\nOK',
[
{ name: "script1", content: "", active: true },
{ name: "script2", content: "", active: false },
{ name: "script3", content: "", active: true },
],
],
])(
"should parse %s",
(_description: string, input: string, expected: SieveScript[]) => {
const result = testableService.testParseScriptList(input);
expect(result).toEqual(expected);
},
);
it.each([
[
"unquoted script with ACTIVE",
"script1 ACTIVE\r\nOK",
[{ name: "script1", content: "", active: true }],
],
[
"unquoted script without ACTIVE",
"script2\r\nOK",
[{ name: "script2", content: "", active: false }],
],
[
"mixed quoted and unquoted",
'"quoted" ACTIVE\r\nunquoted\r\nOK',
[
{ name: "quoted", content: "", active: true },
{ name: "unquoted", content: "", active: false },
],
],
])(
"should parse %s",
(_description: string, input: string, expected: SieveScript[]) => {
const result = testableService.testParseScriptList(input);
expect(result).toEqual(expected);
},
);
it("should handle ACTIVE on separate line as script name", () => {
// Note: The parser treats "ACTIVE" on its own line as a script name
// This is the current behavior - ACTIVE must be on same line as script name
const input = "script1\r\nACTIVE\r\nOK";
const result = testableService.testParseScriptList(input);
// Parser creates two scripts: "script1" and "ACTIVE"
expect(result.length).toBe(2);
expect(result[0].name).toBe("script1");
expect(result[1].name).toBe("ACTIVE");
// The second-pass marks the last script (ACTIVE) as active
expect(result[1].active).toBe(true);
});
it.each([
["empty response", "OK", []],
["only OK line", "OK Listscripts completed\r\n", []],
["only NO line", "NO Failed\r\n", []],
["whitespace lines", "\r\n\r\n\r\nOK", []],
])(
"should handle %s",
(_description: string, input: string, expected: SieveScript[]) => {
const result = testableService.testParseScriptList(input);
expect(result).toEqual(expected);
},
);
it("should skip lines starting with OK or NO", () => {
// OK and NO lines are status responses, not script names
const input = '"script1"\r\nOK Some text\r\n"script2"\r\n';
const result = testableService.testParseScriptList(input);
// Both scripts are parsed (OK line is skipped)
expect(result.length).toBe(2);
expect(result[0].name).toBe("script1");
expect(result[1].name).toBe("script2");
});
});
describe("parseCapabilities - branch coverage", () => {
let testableService: TestableSieveService;
beforeEach(() => {
testableService = new TestableSieveService(mockConfig);
});
it.each([
[
"quoted IMPLEMENTATION",
'"IMPLEMENTATION" "Dovecot Pigeonhole"\r\nOK',
{
implementation: "Dovecot Pigeonhole",
version: "",
saslMechanisms: [],
sieveExtensions: [],
},
],
[
"quoted VERSION",
'"VERSION" "1.0"\r\nOK',
{
implementation: "",
version: "1.0",
saslMechanisms: [],
sieveExtensions: [],
},
],
[
"quoted SASL",
'"SASL" "PLAIN LOGIN"\r\nOK',
{
implementation: "",
version: "",
saslMechanisms: ["PLAIN", "LOGIN"],
sieveExtensions: [],
},
],
[
"quoted SIEVE",
'"SIEVE" "fileinto envelope body"\r\nOK',
{
implementation: "",
version: "",
saslMechanisms: [],
sieveExtensions: ["fileinto", "envelope", "body"],
},
],
[
"quoted STARTTLS",
'"STARTTLS"\r\nOK',
{
implementation: "",
version: "",
saslMechanisms: [],
sieveExtensions: ["STARTTLS"],
},
],
])(
"should parse %s",
(_description: string, input: string, expected: SieveCapabilities) => {
const result = testableService.testParseCapabilities(input);
expect(result).toEqual(expected);
},
);
it.each([
[
"unquoted IMPLEMENTATION",
'IMPLEMENTATION "Dovecot"\r\nOK',
{
implementation: "Dovecot",
version: "",
saslMechanisms: [],
sieveExtensions: [],
},
],
[
"unquoted VERSION",
'VERSION "2.0"\r\nOK',
{
implementation: "",
version: "2.0",
saslMechanisms: [],
sieveExtensions: [],
},
],
[
"unquoted SASL",
'SASL "PLAIN"\r\nOK',
{
implementation: "",
version: "",
saslMechanisms: ["PLAIN"],
sieveExtensions: [],
},
],
[
"unquoted SIEVE",
'SIEVE "fileinto"\r\nOK',
{
implementation: "",
version: "",
saslMechanisms: [],
sieveExtensions: ["fileinto"],
},
],
[
"unquoted STARTTLS",
"STARTTLS\r\nOK",
{
implementation: "",
version: "",
saslMechanisms: [],
sieveExtensions: ["STARTTLS"],
},
],
])(
"should parse %s",
(_description: string, input: string, expected: SieveCapabilities) => {
const result = testableService.testParseCapabilities(input);
expect(result).toEqual(expected);
},
);
it.each([
[
"complete quoted capabilities",
'"IMPLEMENTATION" "Dovecot Pigeonhole"\r\n"VERSION" "1.0"\r\n"SASL" "PLAIN LOGIN"\r\n"SIEVE" "fileinto envelope"\r\n"STARTTLS"\r\nOK',
{
implementation: "Dovecot Pigeonhole",
version: "1.0",
saslMechanisms: ["PLAIN", "LOGIN"],
sieveExtensions: ["fileinto", "envelope", "STARTTLS"],
},
],
[
"complete unquoted capabilities",
'IMPLEMENTATION "Cyrus"\r\nVERSION "2.0"\r\nSASL "PLAIN"\r\nSIEVE "fileinto"\r\nSTARTTLS\r\nOK',
{
implementation: "Cyrus",
version: "2.0",
saslMechanisms: ["PLAIN"],
sieveExtensions: ["fileinto", "STARTTLS"],
},
],
[
"mixed quoted and unquoted",
'"IMPLEMENTATION" "Dovecot"\r\nVERSION "1.0"\r\n"SASL" "PLAIN"\r\nSIEVE "fileinto"\r\nOK',
{
implementation: "Dovecot",
version: "1.0",
saslMechanisms: ["PLAIN"],
sieveExtensions: ["fileinto"],
},
],
])(
"should parse %s",
(_description: string, input: string, expected: SieveCapabilities) => {
const result = testableService.testParseCapabilities(input);
expect(result).toEqual(expected);
},
);
it.each([
[
"empty response",
"OK",
{
implementation: "",
version: "",
saslMechanisms: [],
sieveExtensions: [],
},
],
[
"whitespace lines",
"\r\n\r\n\r\nOK",
{
implementation: "",
version: "",
saslMechanisms: [],
sieveExtensions: [],
},
],
[
"unknown capability line",
"UNKNOWN_CAP value\r\nOK",
{
implementation: "",
version: "",
saslMechanisms: [],
sieveExtensions: [],
},
],
])(
"should handle %s",
(_description: string, input: string, expected: SieveCapabilities) => {
const result = testableService.testParseCapabilities(input);
expect(result).toEqual(expected);
},
);
});
});