Files
AriWeb-Suite/modules/arimail/server/automation.mjs
T

191 lines
16 KiB
JavaScript

/**
* 수신 메일 자동 처리(0.8.16~): 메일 전달과 자동 응답.
*
* 설정 화면(prefs, auth.mjs 의 DEFAULT_PREFS 참고)에서 사용자가 켜 두면, 받은 메일을 저장한 직후(store.ingestReceived)
* 이 모듈의 autoProcess() 가 불려 다음을 합니다.
* - 메일 전달(forward): 받은 메일을 지정한 주소(최대 5개)로 통째로(제목·본문·첨부·인라인 이미지) 보냅니다. 원본은 메일함에 그대로 남습니다.
* 보내는 사람은 이 메일함 주소(발송 서비스가 우리 도메인만 허용)이고, 답장이 원래 보낸 사람에게 가도록 Reply-To 를 원래 발신자로 둡니다.
* - 자동 응답(reply): 사용자가 정한 메시지로 보낸 사람에게 회신합니다. 기한 없음이거나 설정한 기간(시작~종료) 안일 때만 동작하며,
* 같은 상대에게는 24시간에 한 번만 보냅니다(한 사람이 여러 통을 보내도 한 번만 답함). 보낸 자동 응답은 보낸 메일함에 남깁니다.
*
* 되풀이(loop) 방지 — 자동 발송 메일은 받는 쪽의 자동 처리가 다시 반응하지 않게 표시를 달고, 받을 때는 그 표시를 보고 건너뜁니다.
* - 자동 응답에는 RFC 3834 의 Auto-Submitted: auto-replied (와 Microsoft 용 X-Auto-Response-Suppress) 를 붙입니다.
* - 전달 메일에는 X-Ari-Auto-Forwarded 헤더를 붙입니다. 두 메일함이 서로를 전달 대상으로 두어도 한 번만 전달되고 멈춥니다.
* - 받은 메일에 Auto-Submitted(no 제외)·Precedence bulk/list/junk·List-Id 가 있거나 발신자가 mailer-daemon/no-reply 류이면 자동 응답하지 않습니다.
* - 72시간보다 오래된 메일(동기화가 뒤늦게 가져온 옛 메일)에는 아무것도 하지 않습니다.
*
* 처리 기록은 auto_actions 테이블(message_id + kind 가 기본 키)에 남깁니다. 먼저 'pending' 행을 넣어 자리를 잡기 때문에
* 웹훅과 동기화가 같은 메일을 거의 동시에 처리해도 전달·응답이 두 번 나가지 않습니다. 결과는 메일 상세의 "자동 처리" 줄에 보입니다.
*/
import { sendMail } from './providers/index.mjs';
import { mailboxUser, readPrefs } from './auth.mjs';
import { now, uid, clean, addresses, address, isEmail } from './util.mjs';
import { sanitizeHtml, textToHtml } from './sanitize.mjs';
// store.mjs 와는 서로 들여옵니다(store 가 저장 직후 autoProcess 를 부름). ESM 은 함수 선언을 호출 시점에 찾으므로 순환이어도 문제없습니다.
import { buildStoredEml, storeRaw, setFlag } from './store.mjs';
import { monthlyBlocked } from './limits.mjs';
/** 이보다 오래된 수신 메일은 자동 처리하지 않습니다(72시간). 동기화가 옛 메일을 뒤늦게 가져올 때 뜬금없는 전달·응답이 나가는 것을 막습니다. */
export const STALE_MS = 72 * 3600 * 1000;
/** 같은 상대에게 자동 응답을 다시 보내기까지의 간격(24시간). */
export const REPLY_INTERVAL_MS = 24 * 3600 * 1000;
/** 전달 메일에 붙이는 표시 헤더. 받은 메일에 이 헤더가 있으면 다시 전달하지 않습니다(전달 고리 방지). 값은 전달한 메일함 주소입니다. */
export const FORWARD_MARKER = 'X-Ari-Auto-Forwarded';
/** 전달 한 통에 넣는 첨부 바이트 합계 상한. 발송 서비스 한도(40MB)와 base64 부풀림을 고려해 25MB 로 둡니다. 넘는 첨부는 빼고 기록에 남깁니다. */
const FORWARD_ATTACH_LIMIT = 25 * 1024 * 1024;
/**
* 자동 응답이 지금 동작하는 상태인지. 설정이 켜져 있고 메시지가 있어야 하며, 기한 없음(noLimit)이면 항상,
* 아니면 시작(start, 비우면 바로)~종료(end) 사이일 때만 true. 설정 화면의 "작동 중" 표시와 수신 처리가 같은 규칙을 씁니다.
* @param {object} a prefs.autoReply
* @param {number} [at] 판단 시각(ms). 기본은 지금. 테스트에서 기간 경계를 확인하는 데 씁니다.
*/
export function autoReplyActive(a, at = Date.now()) {
if (!a || !a.enabled || !String(a.message || '').trim()) return false;
if (a.noLimit) return true;
const start = a.start ? Date.parse(a.start) : -Infinity, end = a.end ? Date.parse(a.end) : NaN;
if (!Number.isFinite(end)) return false; // 기한 제한인데 종료 시각이 없으면(저장 검사를 거치지 않은 옛 값) 동작하지 않습니다.
return at >= start && at <= end;
}
/** headers_json 객체에서 이름을 대소문자 구분 없이 찾습니다. 값이 배열이면 첫 값. 없으면 ''. */
function header(headers, name) {
const key = Object.keys(headers || {}).find((k) => k.toLowerCase() === name.toLowerCase());
const v = key ? headers[key] : ''; return String(Array.isArray(v) ? v[0] : v ?? '').trim();
}
/** "홍길동 <hong@x>" 에서 표시 이름(홍길동)만. 이름이 없으면 주소의 @ 앞부분. */
function displayName(sender) {
const s = String(sender || '').trim(); const m = s.match(/^"?(.*?)"?\s*<[^<>]+>$/);
return (m && m[1] ? m[1] : s.split('@')[0]).replace(/[\r\n"<>]/g, '').trim();
}
/**
* 자동 발송된 메일(다른 서버의 자동 응답·알림·목록 메일 등)인지 판단해 이유를 돌려줍니다. 사람이 보낸 메일이면 null.
* 'auto-submitted' Auto-Submitted 가 no 가 아님(RFC 3834) / 'forwarded' 우리 전달 표시 / 'bulk' Precedence bulk·list·junk·auto_reply
* 'list' 메일링 리스트 헤더 / 'suppressed' X-Auto-Response-Suppress(Microsoft) / 'noreply' 발신 주소가 mailer-daemon·no-reply 류
* @param {object} mail messages 행
* @param {object} headers headers_json 을 푼 객체
*/
export function automatedReason(mail, headers) {
const auto = header(headers, 'Auto-Submitted').toLowerCase();
if (auto && auto !== 'no') return 'auto-submitted';
if (header(headers, FORWARD_MARKER)) return 'forwarded';
if (/^(bulk|list|junk|auto_reply)$/i.test(header(headers, 'Precedence'))) return 'bulk';
if (header(headers, 'List-Id') || header(headers, 'List-Unsubscribe')) return 'list';
if (/\b(all|autoreply|oof)\b/i.test(header(headers, 'X-Auto-Response-Suppress'))) return 'suppressed';
const from = address(mail.sender);
if (!isEmail(from) || /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounces?|notifications?)([+@.-]|$)/i.test(from)) return 'noreply';
return null;
}
/** auto_actions 에 처리 중(pending) 행을 넣어 자리를 잡습니다. 이미 행이 있으면(다른 경로가 먼저 처리) false. */
async function claim(env, mail, kind) {
const r = await env.DB.prepare("INSERT OR IGNORE INTO auto_actions(message_id,kind,mailbox,target,status,created_at) VALUES(?,?,?,'','pending',?)").bind(mail.id, kind, mail.mailbox, now()).run();
return r.meta.changes > 0;
}
async function finish(env, mail, kind, { status, target = '', detail = null, sentMessageId = null }) {
await env.DB.prepare('UPDATE auto_actions SET status=?,target=?,detail=?,sent_message_id=?,created_at=? WHERE message_id=? AND kind=?').bind(status, target, detail ? String(detail).slice(0, 300) : null, sentMessageId, now(), mail.id, kind).run();
return { kind, status, target, detail };
}
/**
* 받은 메일 한 통에 메일함 설정에 따라 전달·자동 응답을 수행합니다. store.ingestReceived 가 저장 직후에 부릅니다.
* 예외는 밖으로 던지지 않고 기록(failed)만 남깁니다 — 자동 처리 실패가 수신 저장 자체를 실패로 만들면 안 됩니다.
* @param {string} messageId 저장한 메일 id
* @returns {Promise<{forward?:object, reply?:object}|null>} 작업별 결과(테스트·로그용). 설정이 모두 꺼져 있으면 null.
*/
export async function autoProcess(env, messageId) {
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(messageId).first();
if (!mail || mail.direction !== 'inbound') return null;
const user = await mailboxUser(env, mail.mailbox); if (!user) return null;
const prefs = await readPrefs(env, user);
const wantForward = prefs.forward.enabled && addresses(prefs.forward.to).length > 0, wantReply = autoReplyActive(prefs.autoReply);
if (!wantForward && !wantReply) return null;
let headers = {}; try { headers = JSON.parse(mail.headers_json || '{}'); } catch { headers = {}; }
const stale = Date.now() - Date.parse(mail.received_at) > STALE_MS;
const out = {};
if (wantForward) out.forward = await runForward(env, mail, user, prefs.forward, headers, stale);
if (wantReply) out.reply = await runReply(env, mail, user, prefs.autoReply, headers, stale);
return out;
}
/**
* 메일 전달. 저장된 원본 HTML(정제 전, 인라인 이미지 cid 포함)·텍스트·첨부를 그대로 실어 지정 주소로 보냅니다.
* 건너뛰는 경우: 우리 전달 표시가 있는 메일(loop), 전달 대상이 보낸 메일(self: 다시 돌려보내지 않음), 72시간 지난 메일(stale).
*/
async function runForward(env, mail, user, forward, headers, stale) {
const kind = 'forward';
const targets = addresses(forward.to).filter((a) => isEmail(a) && a !== mail.mailbox);
if (!targets.length) return { kind, status: 'skipped', detail: 'no_target' };
if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' };
const sender = address(mail.sender);
if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' });
if (header(headers, FORWARD_MARKER)) return finish(env, mail, kind, { status: 'skipped', detail: 'loop' });
if (targets.includes(sender)) return finish(env, mail, kind, { status: 'skipped', detail: 'self' });
// 월간 수발신 통수 제한(0.9.0, 시스템 설정)에 이르렀으면 전달도 보내지 않습니다(전달 한 통도 발신으로 셈).
if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', detail: 'limit' });
try {
const rows = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(mail.id).all()).results;
// 첨부는 합계 상한까지만 싣습니다. 상한을 넘겨 뺀 첨부 이름은 기록에 남겨 상세 화면에서 알 수 있게 합니다.
let total = 0; const files = [], dropped = [];
for (const a of rows) { if (!a.bytes) { dropped.push(a.filename); continue; } if (total + a.bytes.length > FORWARD_ATTACH_LIMIT) { dropped.push(a.filename); continue; } total += a.bytes.length; files.push(a); }
const html = mail.original_html || mail.html || '';
const payload = {
// 표시 이름에 괄호와 @ 가 들어가므로 따옴표로 묶습니다(RFC 5322: 괄호는 주석, @ 는 따옴표 없는 이름에 쓸 수 없음).
from: `"${displayName(mail.sender) || sender} (via ${mail.mailbox})" <${mail.mailbox}>`, to: targets,
subject: mail.subject || '(no subject)', text: mail.text || '', ...(html ? { html } : {}),
// 전달받은 사람이 "답장" 을 누르면 원래 보낸 사람에게 가도록 합니다(우리 메일함이 아니라).
...(isEmail(sender) ? { reply_to: [mail.sender] } : {}),
headers: { [FORWARD_MARKER]: mail.mailbox },
};
if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(a.content_id ? { content_id: a.content_id } : {}) }));
// 멱등 키: 같은 메일을 다시 처리하게 되더라도 발송 서비스가 한 번만 보냅니다.
const result = await sendMail(env, payload, 'auto-forward-' + mail.id);
return finish(env, mail, kind, { status: 'sent', target: targets.join(', '), detail: (result.id || '') + (dropped.length ? ' dropped:' + dropped.join(',') : '') });
} catch (e) {
console.error('메일 전달 실패', mail.id, mail.mailbox, '→', targets.join(','), e.message, e.providerMessage || '');
return finish(env, mail, kind, { status: 'failed', target: targets.join(', '), detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') });
}
}
/**
* 자동 응답. Reply-To(없으면 From)에게 설정한 제목·메시지로 회신하고 보낸 메일함에 저장합니다.
* 건너뛰는 경우: 자동 발송 메일(automatedReason), 72시간 지난 메일(stale), 같은 상대에게 24시간 안에 이미 보냄(recent), 회신 주소 없음(no_address).
*/
async function runReply(env, mail, user, autoReply, headers, stale) {
const kind = 'reply';
let replyTo = []; try { replyTo = JSON.parse(mail.reply_to_json || '[]'); } catch { replyTo = []; }
const target = addresses(replyTo)[0] || address(mail.sender);
if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' };
if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' });
if (!isEmail(target)) return finish(env, mail, kind, { status: 'skipped', detail: 'no_address' });
const reason = automatedReason(mail, headers);
if (reason) return finish(env, mail, kind, { status: 'skipped', target, detail: reason });
if (target === mail.mailbox) return finish(env, mail, kind, { status: 'skipped', target, detail: 'self' });
const recent = await env.DB.prepare("SELECT created_at FROM auto_actions WHERE mailbox=? AND kind='reply' AND target=? AND status='sent' AND created_at>? AND message_id<>? LIMIT 1").bind(mail.mailbox, target, new Date(Date.now() - REPLY_INTERVAL_MS).toISOString(), mail.id).first();
if (recent) return finish(env, mail, kind, { status: 'skipped', target, detail: 'recent' });
if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', target, detail: 'limit' });
try {
const original = String(mail.subject || '').trim();
const subject = clean(autoReply.subject, 200) || (/^re:/i.test(original) ? original : 'Re: ' + original).trim() || 'Re:';
const text = String(autoReply.message || '').trim(), content = sanitizeHtml(textToHtml(text));
const fromName = (user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang').replace(/[\r\n"<>]/g, '').trim();
const threadHeaders = mail.message_id ? { 'In-Reply-To': mail.message_id, References: mail.message_id } : {};
const payload = { from: `${fromName} <${mail.mailbox}>`, to: [target], subject, text, html: content.html, headers: { ...threadHeaders, 'Auto-Submitted': 'auto-replied', 'X-Auto-Response-Suppress': 'All' } };
const result = await sendMail(env, payload, 'auto-reply-' + mail.id);
// 보낸 메일함에 남깁니다(사용자가 무엇이 나갔는지 볼 수 있게). 원본(.eml)은 저장한 부품으로 재구성합니다.
const id = uid(), stamp = now();
await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,subject,html,text,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,'web','sent',?,?,?,?,?,?)`)
.bind(id, result.id || null, payload.from, JSON.stringify([target]), subject, content.html, text, JSON.stringify(payload.headers), stamp, stamp, stamp, stamp, stamp, mail.mailbox).run();
await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built');
await setFlag(env, mail.id, '\\Answered', true); // 원래 메일에 "답장함" 표시(IMAP \Answered 와 공유)
return finish(env, mail, kind, { status: 'sent', target, detail: result.id || null, sentMessageId: id });
} catch (e) {
console.error('자동 응답 실패', mail.id, mail.mailbox, '→', target, e.message, e.providerMessage || '');
return finish(env, mail, kind, { status: 'failed', target, detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') });
}
}
/** 메일 상세 화면용: 이 메일에 한 자동 처리 기록. */
export async function autoActionsOf(env, messageId) {
return (await env.DB.prepare('SELECT kind,target,status,detail,sent_message_id,created_at FROM auto_actions WHERE message_id=? ORDER BY kind').bind(messageId).all()).results;
}