175 lines
4.6 KiB
JavaScript
175 lines
4.6 KiB
JavaScript
|
|
/**
|
||
|
|
* rspamd 스팸 필터 서비스
|
||
|
|
* 스팸 검사, 학습 API 연동
|
||
|
|
*/
|
||
|
|
|
||
|
|
const RSPAMD_HOST = process.env.RSPAMD_HOST || "rspamd";
|
||
|
|
const RSPAMD_PORT = process.env.RSPAMD_PORT || 11333;
|
||
|
|
const RSPAMD_BASE_URL = `http://${RSPAMD_HOST}:${RSPAMD_PORT}`;
|
||
|
|
|
||
|
|
// 스팸 점수 임계값 (이 값 이상이면 스팸으로 분류)
|
||
|
|
const SPAM_THRESHOLD = 6.0;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이메일 스팸 검사
|
||
|
|
* @param {string} rawEmail - 원본 이메일 데이터 (RFC822 형식)
|
||
|
|
* @returns {Promise<{isSpam: boolean, score: number, action: string}>}
|
||
|
|
*/
|
||
|
|
const checkSpam = async (rawEmail) => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${RSPAMD_BASE_URL}/checkv2`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "text/plain",
|
||
|
|
},
|
||
|
|
body: rawEmail,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
console.error("[rspamd] 검사 실패:", response.status);
|
||
|
|
return { isSpam: false, score: 0, action: "no action" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await response.json();
|
||
|
|
|
||
|
|
// action: no action, greylist, add header, rewrite subject, soft reject, reject
|
||
|
|
const isSpam =
|
||
|
|
result.score >= SPAM_THRESHOLD ||
|
||
|
|
result.action === "reject" ||
|
||
|
|
result.action === "add header" ||
|
||
|
|
result.action === "rewrite subject";
|
||
|
|
|
||
|
|
console.log(
|
||
|
|
`[rspamd] 검사 결과: score=${result.score}, action=${result.action}, isSpam=${isSpam}`
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
isSpam,
|
||
|
|
score: result.score || 0,
|
||
|
|
action: result.action || "no action",
|
||
|
|
symbols: result.symbols || {},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[rspamd] 검사 오류:", error.message);
|
||
|
|
// rspamd 연결 실패 시 스팸이 아닌 것으로 처리
|
||
|
|
return { isSpam: false, score: 0, action: "no action" };
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이메일을 스팸으로 학습
|
||
|
|
* @param {string} rawEmail - 원본 이메일 데이터
|
||
|
|
* @returns {Promise<boolean>}
|
||
|
|
*/
|
||
|
|
const learnSpam = async (rawEmail) => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${RSPAMD_BASE_URL}/learnspam`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "text/plain",
|
||
|
|
},
|
||
|
|
body: rawEmail,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
console.error("[rspamd] 스팸 학습 실패:", response.status);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await response.json();
|
||
|
|
console.log("[rspamd] 스팸 학습 완료:", result);
|
||
|
|
return result.success !== false;
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[rspamd] 스팸 학습 오류:", error.message);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이메일을 정상 메일(ham)로 학습
|
||
|
|
* @param {string} rawEmail - 원본 이메일 데이터
|
||
|
|
* @returns {Promise<boolean>}
|
||
|
|
*/
|
||
|
|
const learnHam = async (rawEmail) => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${RSPAMD_BASE_URL}/learnham`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "text/plain",
|
||
|
|
},
|
||
|
|
body: rawEmail,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
console.error("[rspamd] 햄 학습 실패:", response.status);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await response.json();
|
||
|
|
console.log("[rspamd] 햄 학습 완료:", result);
|
||
|
|
return result.success !== false;
|
||
|
|
} catch (error) {
|
||
|
|
console.error("[rspamd] 햄 학습 오류:", error.message);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* rspamd 상태 확인
|
||
|
|
* @returns {Promise<boolean>}
|
||
|
|
*/
|
||
|
|
const checkHealth = async () => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${RSPAMD_BASE_URL}/ping`, {
|
||
|
|
method: "GET",
|
||
|
|
});
|
||
|
|
return response.ok;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이메일 객체를 RFC822 형식으로 변환
|
||
|
|
* @param {Object} email - 이메일 객체 (from, to, subject, text, html)
|
||
|
|
* @returns {string}
|
||
|
|
*/
|
||
|
|
const buildRawEmail = (email) => {
|
||
|
|
const boundary = "----=_Part_" + Date.now();
|
||
|
|
let raw = "";
|
||
|
|
|
||
|
|
raw += `From: ${email.from || "unknown@unknown.com"}\r\n`;
|
||
|
|
raw += `To: ${email.to || "unknown@unknown.com"}\r\n`;
|
||
|
|
raw += `Subject: ${email.subject || "(no subject)"}\r\n`;
|
||
|
|
raw += `Date: ${
|
||
|
|
email.date ? new Date(email.date).toUTCString() : new Date().toUTCString()
|
||
|
|
}\r\n`;
|
||
|
|
raw += `Message-ID: ${email.messageId || `<${Date.now()}@local>`}\r\n`;
|
||
|
|
raw += `MIME-Version: 1.0\r\n`;
|
||
|
|
|
||
|
|
if (email.html) {
|
||
|
|
raw += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
|
||
|
|
raw += `--${boundary}\r\n`;
|
||
|
|
raw += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
||
|
|
raw += `${email.text || ""}\r\n`;
|
||
|
|
raw += `--${boundary}\r\n`;
|
||
|
|
raw += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
|
||
|
|
raw += `${email.html}\r\n`;
|
||
|
|
raw += `--${boundary}--\r\n`;
|
||
|
|
} else {
|
||
|
|
raw += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
|
||
|
|
raw += `${email.text || ""}\r\n`;
|
||
|
|
}
|
||
|
|
|
||
|
|
return raw;
|
||
|
|
};
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
checkSpam,
|
||
|
|
learnSpam,
|
||
|
|
learnHam,
|
||
|
|
checkHealth,
|
||
|
|
buildRawEmail,
|
||
|
|
SPAM_THRESHOLD,
|
||
|
|
};
|