Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
575 lines
56 KiB
JavaScript
575 lines
56 KiB
JavaScript
/**
|
|
* 메일 저장소 모듈.
|
|
*
|
|
* 하는 일
|
|
* - 수신: Resend 웹훅·정기 동기화가 알려 준 메일을 가져와 원본(.eml)·첨부·정제 본문을 SQLite 에 저장합니다.
|
|
* - 보낸 메일: 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다.
|
|
* - 동기화: Resend 목록을 훑어 웹훅이 놓친 메일을 보충합니다(전체 훑기 / 빠른 확인 두 가지 방식).
|
|
* - 정리: 만료된 로그인 세션을 지웁니다.
|
|
* - 초안·발송, IMAP APPEND/COPY 저장.
|
|
*
|
|
* 보관 정책(0.8.0~)
|
|
* 메일은 기한 없이 보관합니다. 사용자가 휴지통에서 "영구 삭제"할 때만 지워지며, 그때 남기는 tombstone 이
|
|
* 동기화가 같은 메일을 다시 가져오지 않게 막습니다. 예전의 30일 자동 삭제(RETENTION_DAYS)는 없앴습니다.
|
|
* messages.expires_at 열은 이전 릴리스로 되돌릴 때 스키마가 맞도록 남겨 두었지만 더는 쓰지 않습니다(항상 NULL).
|
|
*/
|
|
import { resend, download } from './providers/resend.mjs';
|
|
import { sendMail, inboundMode, outboundMode } from './providers/index.mjs';
|
|
import { sanitizeHtml, textToHtml } from './sanitize.mjs';
|
|
import { buildEml, normalizeCrlf } from './mime.mjs';
|
|
import { now, uid, clean, isEmail, addresses, address, formatSender, nameOf } from './util.mjs';
|
|
import { parseMime, summarize } from './imap-mime.mjs';
|
|
import { knownMailboxes } from './auth.mjs';
|
|
import { autoProcess } from './automation.mjs';
|
|
import { checkOutbound, checkInbound, attachmentLimit, mimeSize, mailboxQuota, mailboxUsage, systemLimits, totalUsage } from './limits.mjs';
|
|
import { checkLargeFileTerms, finalizeLargeFiles } from './files.mjs';
|
|
|
|
/** 기본 메일함 주소(환경 변수 MAILBOX, 소문자). 수신자가 비어 있는 메일과 계정 없는 발송의 기본값입니다. */
|
|
export const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
|
|
/** 발신 표시 이름의 기본값. 계정 이름이 없을 때 "이름 <주소>" 의 이름 부분으로 씁니다. */
|
|
const senderName = (env) => String(env.MAIL_FROM_NAME || 'Ari Hak Dang');
|
|
// ATTACHMENT_LIMIT: 수신 첨부 한 개를 내려받는 최대 크기, RAW_LIMIT: 원본 .eml 최대 크기. 넘으면 바이트 없이 목록만 남깁니다.
|
|
const ATTACHMENT_LIMIT = 25 * 1024 * 1024, RAW_LIMIT = 40 * 1024 * 1024;
|
|
// 받은 메일·초안 HTML 의 정제 한도(글자 수). 뉴스레터는 인라인 CSS 때문에 수백 KB 가 흔해 화면 입력 한도(40만 자)보다 크게 둡니다.
|
|
const STORED_HTML_LIMIT = 5 * 1000 * 1000;
|
|
|
|
/**
|
|
* 저장용 본문 정제. HTML 이 한도를 넘어도 예외를 던지지 않고 텍스트 본문을 HTML 로 바꿔 대신 씁니다.
|
|
* 왜: 예전에는 40만 자가 넘는 HTML 메일에서 sanitizeHtml 이 예외를 던져 수신 저장 전체가 실패했습니다.
|
|
* 웹훅은 500 을 돌려 Resend 가 계속 재시도하고, 동기화도 매번 실패해 그 메일은 영영 메일함에 들어오지 못했습니다.
|
|
* @param {string|null} html 원본 HTML(없으면 텍스트를 씀)
|
|
* @param {string} text 텍스트 본문(HTML 이 없거나 너무 클 때 대신 표시)
|
|
*/
|
|
export function safeContent(html, text) {
|
|
// 텍스트만 있는 메일도 같은 한도(STORED_HTML_LIMIT)를 씁니다. 예전에는 기본 한도(40만 자)로 정제해 40만 자가 넘는 텍스트 메일(로그 첨부 본문 등)의 저장이 실패했습니다(0.9.0 수정).
|
|
if (!html) { try { return sanitizeHtml(textToHtml(text || ''), { limit: STORED_HTML_LIMIT }); } catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); } }
|
|
try { return sanitizeHtml(html, { limit: STORED_HTML_LIMIT }); }
|
|
catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); }
|
|
}
|
|
|
|
/**
|
|
* 시각 문자열을 ISO 8601 UTC(예: 2026-09-28T06:20:26.514Z)로 맞춥니다.
|
|
*
|
|
* 왜 필요한가: Resend 의 보낸 메일 목록은 created_at 을 "2026-09-28 06:20:26.514000+00" 처럼 공백과 짧은
|
|
* 시간대 표기로 돌려줍니다. 이 값을 그대로 received_at 에 넣으면, 다른 메일(ISO 형식)과 같은 날짜일 때
|
|
* 문자열 정렬(ORDER BY received_at)에서 공백(0x20)이 'T'(0x54)보다 앞서 시각과 무관하게 순서가 뒤섞였습니다.
|
|
* 저장 전에 한 형식으로 맞추면 목록·POP·IMAP 의 날짜순 정렬이 실제 시각 순서와 같아집니다.
|
|
*
|
|
* @param {string|undefined|null} value Resend 등에서 받은 시각 문자열
|
|
* @param {string} [fallback] 해석할 수 없을 때 쓸 값(기본: 지금)
|
|
* @returns {string} ISO 8601 UTC 문자열
|
|
*/
|
|
export function isoTime(value, fallback = now()) {
|
|
if (!value) return fallback;
|
|
// "+00" 처럼 분이 빠진 시간대는 Date.parse 가 환경에 따라 거부할 수 있어 "+00:00" 으로 보완합니다.
|
|
// 시각 부분(HH:MM[:SS[.fff]]) 바로 뒤에 붙은 경우만 고쳐, "2026-09-28" 같은 날짜만 있는 값의 "-28" 을 건드리지 않습니다.
|
|
const text = String(value).trim().replace(/(\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)([+-]\d{2})$/, '$1$2:00');
|
|
const ms = Date.parse(text);
|
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : fallback;
|
|
}
|
|
|
|
function recipientsOf(full) { return addresses([...(full.to || []), ...(full.cc || []), ...(full.bcc || []), ...(full.received_for || [])]); }
|
|
|
|
/** 원본(.eml)을 message_raw 에 넣고 messages.raw_status/raw_size 를 갱신합니다. automation.mjs(자동 응답의 보낸 메일 원본)도 씁니다. */
|
|
export async function storeRaw(env, messageId, eml, status) {
|
|
await env.DB.batch([
|
|
env.DB.prepare('INSERT OR REPLACE INTO message_raw(message_id,eml,stored_at) VALUES(?,?,?)').bind(messageId, eml, now()),
|
|
env.DB.prepare('UPDATE messages SET raw_status=?,raw_size=?,updated_at=? WHERE id=?').bind(status, eml.length, now(), messageId),
|
|
]);
|
|
}
|
|
async function attachmentRows(env, messageId) {
|
|
return (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(messageId).all()).results;
|
|
}
|
|
export async function buildStoredEml(env, message) {
|
|
const rows = await attachmentRows(env, message.id);
|
|
return buildEml({
|
|
from: message.sender, to: JSON.parse(message.recipients_json || '[]'), cc: JSON.parse(message.cc_json || '[]'), bcc: JSON.parse(message.bcc_json || '[]'), replyTo: JSON.parse(message.reply_to_json || '[]'),
|
|
subject: message.subject, date: message.received_at, messageId: message.message_id || undefined, headers: JSON.parse(message.headers_json || '{}'),
|
|
text: message.text, html: message.original_html || message.html || '',
|
|
attachments: rows.filter((a) => a.bytes).map((a) => ({ filename: a.filename, contentType: a.content_type, contentId: a.content_id, disposition: a.disposition, bytes: Buffer.from(a.bytes) })),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 수신 메일 한 통을 Resend 에서 가져와 본문·첨부·원본까지 저장합니다.
|
|
*
|
|
* 건너뛰는 경우(반환값 skipped): 'tombstone' = 사용자가 영구 삭제한 메일, 'exists' = 원본까지 이미 저장한 메일,
|
|
* 'other_mailbox' = 수신자 중 이 게이트웨이가 다루는 메일함이 없는 메일. 동기화의 빠른 확인 방식은
|
|
* 'exists'/'tombstone' 을 "여기부터는 이미 확인함" 표시로 씁니다.
|
|
*
|
|
* @param {object} env
|
|
* @param {string} providerId Resend 수신 메일 id
|
|
* @param {object} [options]
|
|
* @param {'sync'|'webhook'} [options.source] 어떤 경로로 알게 됐는지(설정 화면 통계용)
|
|
* @param {object|null} [options.summary] 목록·웹훅이 준 요약(상세 응답에 값이 없을 때 대신 씁니다)
|
|
* @param {string[]|null} [options.known] 알려진 메일함 주소 목록(반복 호출 시 DB 조회를 아끼려고 넘깁니다)
|
|
*/
|
|
export async function ingestReceived(env, providerId, { source = 'sync', summary = null, known = null } = {}) {
|
|
known = known || await knownMailboxes(env);
|
|
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
|
|
const existing = await env.DB.prepare('SELECT id,raw_status FROM messages WHERE provider_id=?').bind(providerId).first();
|
|
if (existing && existing.raw_status !== 'none') return { skipped: 'exists', id: existing.id };
|
|
const full = await resend(env, '/emails/receiving/' + encodeURIComponent(providerId));
|
|
const recipients = recipientsOf(full);
|
|
// 메일함 라우팅: 수신자 중 알려진 메일함(기본 메일함 + 계정 메일함) 첫 번째가 주 저장본. 수신자가 없으면 기본 메일함.
|
|
// 수신자에 우리 메일함이 둘 이상이면(예: info@ 와 admin@ 에 함께 보낸 메일) 나머지 메일함에도 사본을 둡니다(0.8.24, 예전에는 첫 메일함에만 저장).
|
|
const locals = [...new Set(recipients.filter((r) => known.includes(r)))];
|
|
const mailbox = locals[0] || (recipients.length ? null : mailboxOf(env));
|
|
if (!mailbox) return { skipped: 'other_mailbox' };
|
|
// 메일함·전체 용량 제한(0.9.0): 가득 찬 메일함에는 저장하지 않고 'quota_exceeded' / 'storage_full' 로 건너뜁니다(웹훅 기록·동기화 상세에 남음).
|
|
// 메일은 발송 서비스 쪽에 남아 있으므로 사용자가 메일을 지워 공간이 생기면 다음 동기화가 다시 가져옵니다. 원본만 없던 메일(existing)은 다시 채웁니다.
|
|
if (!existing) { const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox }; }
|
|
const content = safeContent(full.html, full.text);
|
|
// 수신 시각은 ISO 형식으로 맞춰 저장합니다(isoTime 설명 참고). 보관 만료일(expires_at)은 두지 않습니다.
|
|
const receivedAt = isoTime(full.created_at || summary?.created_at), stamp = now();
|
|
const id = existing?.id || uid();
|
|
const values = [full.from || summary?.from || '', JSON.stringify(full.to || summary?.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject || summary?.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), full.message_id || null, receivedAt, source, stamp, mailbox];
|
|
// 원본을 받지 못했던 메일(raw_status='none')은 같은 id 로 내용을 다시 채우고, 처음이면 새로 넣습니다.
|
|
if (existing) await env.DB.prepare('UPDATE messages SET sender=?,recipients_json=?,cc_json=?,bcc_json=?,reply_to_json=?,subject=?,html=?,text=?,original_html=?,headers_json=?,message_id=?,received_at=?,source=?,updated_at=?,mailbox=?,expires_at=NULL WHERE id=?').bind(...values, id).run();
|
|
else {
|
|
// 웹훅과 정기 동기화가 같은 메일을 거의 동시에 만나면 둘 다 위의 "이미 있나" 확인을 통과한 뒤 INSERT 에서 provider_id UNIQUE 에 걸립니다(운영 heart 10/12·10/13 의
|
|
// 동기화 '일부 실패' 기록). 먼저 넣은 쪽이 원본·첨부까지 처리하므로 늦은 쪽은 오류가 아니라 '이미 있음' 으로 조용히 끝냅니다(1.0.11).
|
|
try { await env.DB.prepare(`INSERT INTO messages(sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,message_id,received_at,source,updated_at,mailbox,id,provider_id,direction,folder,state,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'inbound','inbox','received',?)`).bind(...values, id, providerId, stamp).run(); }
|
|
catch (e) { if (/UNIQUE constraint failed: messages\.provider_id/.test(String(e.message))) { const race = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first(); return { skipped: 'exists', id: race?.id }; } throw e; }
|
|
}
|
|
// 첨부: 목록에서 다운로드 주소를 받아 바이트까지 보관합니다.
|
|
const declared = full.attachments || summary?.attachments || [];
|
|
if (declared.length) {
|
|
let list = [];
|
|
try { list = (await resend(env, '/emails/receiving/' + encodeURIComponent(providerId) + '/attachments?limit=100')).data || []; } catch (e) { console.error('첨부 목록 실패', providerId, e.message); }
|
|
await env.DB.prepare('DELETE FROM attachments WHERE message_id=?').bind(id).run();
|
|
// disposition(0.9.9): Content-ID 가 있는 inline 만 'inline'(본문에 끼운 그림)으로 저장하고, 나머지는 모두 'attachment' 입니다. IMAP 으로 올린 메일(imap-mime.mjs)과 같은 규칙입니다.
|
|
// 예전에는 Resend 가 준 "inline" 을 그대로 저장해, Apple Mail 이 "inline; filename=x.pdf" 로 보낸 PDF 가 읽기 창에는 보이면서 전달·첨부 수(📎)·"첨부" 필터에서는 빠졌습니다.
|
|
for (const item of list.length ? list : declared) {
|
|
let bytes = null;
|
|
if (item.download_url && Number(item.size || 0) <= ATTACHMENT_LIMIT) { try { bytes = await download(env, item.download_url, { maxBytes: ATTACHMENT_LIMIT }); } catch (e) { console.error('첨부 다운로드 실패', providerId, item.id, e.message); } }
|
|
await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
|
|
.bind(uid(), id, item.id || null, clean(item.filename, 255) || 'attachment', clean(item.content_type, 120) || 'application/octet-stream', item.content_id ? String(item.content_id).replace(/^<|>$/g, '') : null, /^inline/i.test(String(item.content_disposition || item.disposition || '')) && item.content_id ? 'inline' : 'attachment', Number(item.size || bytes?.length || 0), bytes, stamp).run();
|
|
}
|
|
}
|
|
// 원본: Resend 가 서명한 다운로드 주소를 주면 그대로 저장하고, 없으면 저장한 부품으로 재구성합니다.
|
|
let eml = null, status = 'built';
|
|
if (full.raw?.download_url) { try { eml = await download(env, full.raw.download_url, { maxBytes: RAW_LIMIT }); status = 'stored'; } catch (e) { console.error('원본 다운로드 실패', providerId, e.message); } }
|
|
if (!eml) eml = await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first());
|
|
await storeRaw(env, id, eml, status);
|
|
// 저장이 끝난 뒤 메일함 설정에 따라 전달·자동 응답을 합니다(0.8.16~). 실패해도 수신 저장은 성공으로 끝냅니다
|
|
// (실패는 auto_actions 에 남고 메일 상세에 보입니다). 처리 전에 저장을 마치므로 전달에 첨부·원본 HTML 을 그대로 쓸 수 있습니다.
|
|
let auto = null;
|
|
try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); }
|
|
// 다른 우리 메일함에도 받은 메일: 주 저장본을 복제(원본·첨부 포함)해 그 메일함의 받은 메일함에 둡니다. provider_id 는 UNIQUE 라
|
|
// "<provider id>#<메일함>" 으로 구분해 두어, 동기화가 다시 만나도(주 저장본이 exists) 중복되지 않고 영구 삭제 시 tombstone 도 따로 남습니다.
|
|
const copies = [];
|
|
for (const other of locals.slice(1)) {
|
|
const copyPid = `${providerId}#${other}`;
|
|
if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue;
|
|
if (await checkInbound(env, other)) continue; // 사본을 받을 메일함이 가득 찼으면 건너뜁니다(다음 동기화가 다시 시도).
|
|
const copyId = await copyMessage(env, id, 'inbox');
|
|
await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run();
|
|
try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); }
|
|
copies.push({ id: copyId, mailbox: other });
|
|
}
|
|
return { id, imported: true, raw: status, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) };
|
|
}
|
|
|
|
/**
|
|
* 내장 Haraka(로컬 SMTP 수신)가 넘긴 원본(.eml)을 받은 메일함에 저장합니다(1.0.0, 수신 방식 haraka).
|
|
*
|
|
* Resend 경로(ingestReceived)와 같은 규칙을 지킵니다: provider_id 로 중복·영구 삭제(tombstone) 확인, 메일함 라우팅(수신자 중 우리 메일함 첫 번째가
|
|
* 주 저장본, 나머지 우리 메일함에는 사본), 메일함·전체 용량 검사, 저장 뒤 자동 처리(전달·자동 응답). 원본은 받은 바이트 그대로 보관합니다(raw_status 'stored').
|
|
* provider_id 는 'haraka:<Haraka 트랜잭션 uuid>' 입니다. source 열의 CHECK 제약(webhook|sync|web)을 바꾸려면 messages 표를 다시 만들어야 하므로
|
|
* "밖에서 밀어 넣어 준 메일" 이라는 뜻이 같은 'webhook' 을 쓰고, 통계는 provider_id 접두어로 Haraka 수신을 구분합니다.
|
|
*
|
|
* @param {Buffer} eml 원본 바이트
|
|
* @param {object} o
|
|
* @param {string} o.id Haraka 트랜잭션 id(중복 전달 방지 키)
|
|
* @param {string[]} [o.rcpt] 봉투 받는 사람(RCPT TO). 헤더 To/Cc 보다 우선해 메일함을 정합니다(Bcc 로 받은 메일도 올바른 메일함에).
|
|
* @param {string} [o.from] 봉투 보낸 사람(기록용)
|
|
* @param {string[]} [o.known] 알려진 메일함 목록(반복 호출 시 재사용)
|
|
* @returns {Promise<{id?:string, imported?:boolean, skipped?:string, mailbox?:string, copies?:object[]}>}
|
|
*/
|
|
export async function ingestRaw(env, eml, { id: transactionId, rcpt = [], from = '', known = null } = {}) {
|
|
known = known || await knownMailboxes(env);
|
|
const providerId = 'haraka:' + String(transactionId || uid()).slice(0, 100);
|
|
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
|
|
const existing = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first();
|
|
if (existing) return { skipped: 'exists', id: existing.id };
|
|
eml = normalizeCrlf(eml);
|
|
const info = summarize(parseMime(eml));
|
|
// 봉투 수신자(RCPT TO) → 헤더 수신자 순서로 우리 메일함을 찾습니다. 봉투가 비어 있는 경우(시험·수동 호출)만 헤더를 봅니다.
|
|
const envelope = addresses(rcpt), header = addresses([...(info.to || []), ...(info.cc || []), ...(info.bcc || [])]);
|
|
const locals = [...new Set([...envelope, ...header].filter((r) => known.includes(r)))];
|
|
const mailbox = locals[0];
|
|
if (!mailbox) return { skipped: 'other_mailbox' };
|
|
const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox };
|
|
// appendMessage 가 해석·정제·첨부·원본 저장을 모두 합니다. 수신 시각은 도착 시각(now)입니다 — Date 헤더는 보낸 쪽이 적는 값이라 정렬이 흔들립니다.
|
|
const { id } = await appendMessage(env, 'inbox', eml, { mailbox, enforceQuota: false, internalDate: now() });
|
|
await env.DB.prepare("UPDATE messages SET provider_id=?, source='webhook', updated_at=? WHERE id=?").bind(providerId, now(), id).run();
|
|
let auto = null;
|
|
try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); }
|
|
const copies = [];
|
|
for (const other of locals.slice(1)) {
|
|
const copyPid = `${providerId}#${other}`;
|
|
if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue;
|
|
if (await checkInbound(env, other)) continue;
|
|
const copyId = await copyMessage(env, id, 'inbox');
|
|
await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run();
|
|
try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); }
|
|
copies.push({ id: copyId, mailbox: other });
|
|
}
|
|
console.log(`Haraka 수신 저장 ${mailbox} from=${from || info.from || '-'} id=${id}${copies.length ? ' 사본 ' + copies.length : ''}`);
|
|
return { id, imported: true, mailbox, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) };
|
|
}
|
|
|
|
/**
|
|
* 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다.
|
|
* 발신 주소가 알려진 메일함이 아니면 'other_sender' 로 건너뜁니다. 원본은 저장한 부품으로 재구성합니다.
|
|
*/
|
|
export async function ingestSent(env, providerId, { summary = null, known = null } = {}) {
|
|
known = known || await knownMailboxes(env);
|
|
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
|
|
if (await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first()) return { skipped: 'exists' };
|
|
// 자동 전달(0.8.16)로 나간 메일은 보낸 메일함에 쌓지 않습니다. 예전에는 동기화가 Resend 보낸 목록에서 전달 사본을 다시 가져와
|
|
// 받은 메일 한 통마다 보낸 메일함에 "Sender (via admin@…)" 메일이 생겼습니다(0.8.24). auto_actions 의 전달 기록(detail 이 발송 id 로 시작)으로 알아봅니다.
|
|
if (await env.DB.prepare("SELECT 1 AS x FROM auto_actions WHERE kind='forward' AND status='sent' AND (detail = ? OR detail LIKE ? || ' %')").bind(providerId, providerId).first()) return { skipped: 'auto_forward' };
|
|
const full = await resend(env, '/emails/' + encodeURIComponent(providerId));
|
|
const mailbox = address(full.from);
|
|
if (!known.includes(mailbox)) return { skipped: 'other_sender' };
|
|
const content = safeContent(full.html, full.text);
|
|
// Resend 보낸 메일 목록의 created_at 은 "YYYY-MM-DD HH:MM:SS.ffffff+00" 형식이라 ISO 로 바꿔 저장합니다.
|
|
const sentAt = isoTime(full.created_at || summary?.created_at), stamp = now(), id = uid();
|
|
await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,?,?,?,?,'sync','sent',?,?,?,?,?,?)`)
|
|
.bind(id, providerId, full.from, JSON.stringify(full.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), sentAt, stamp, stamp, sentAt, sentAt, mailbox).run();
|
|
await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built');
|
|
return { id, imported: true };
|
|
}
|
|
|
|
// 동기화는 도메인(env)마다 한 번에 하나만 돕니다. 웹 화면 여러 개가 동시에 요청해도 Resend 호출이 겹치지 않게 합니다.
|
|
// 1.0.7: 예전의 모듈 전역 플래그는 도메인이 여럿이면 한 도메인의 동기화가 다른 도메인을 막으므로 env 단위(SYNCING)로 바꿨습니다.
|
|
/** 지금 이 도메인의 동기화가 진행 중인지(화면의 "동기화 중" 표시용). env 가 없으면 false. */
|
|
export const isSyncing = (env) => !!env?.SYNCING;
|
|
// 한 번의 동기화가 훑는 최대 페이지 수(페이지당 50건). 받은 메일 500건, 보낸 메일 200건까지 거슬러 올라갑니다.
|
|
// 예전에는 "보관 기간(30일)보다 오래된 메일"에서 멈췄지만 보관 기한이 없어졌으므로 이 페이지 한도가 유일한 상한입니다.
|
|
const SYNC_PAGES = { inbound: 10, outbound: 4 };
|
|
|
|
/**
|
|
* Resend 목록(최신순)을 훑어 웹훅이 놓친 메일을 보충합니다.
|
|
*
|
|
* 두 가지 방식이 있습니다.
|
|
* - 전체 훑기(quick=false): 시작 직후·1시간 타이머·설정 화면의 "지금 동기화". 페이지 한도(SYNC_PAGES)까지 모두
|
|
* 확인해 중간에 빠진 메일(예: 일시 오류로 실패한 웹훅)까지 채웁니다.
|
|
* - 빠른 확인(quick=true): 웹 메일함 목록이 1분마다 자동으로 부르는 경우. 목록은 최신순이므로, 이미 저장했거나
|
|
* 영구 삭제(tombstone)한 메일을 처음 만나면 그보다 오래된 메일은 이전 동기화가 확인했다고 보고 멈춥니다.
|
|
* 예전에는 30일 기준 시각에서 멈췄기 때문에 매분 호출해도 가벼웠는데, 기준 시각이 사라진 뒤에도 매분 수백 건을
|
|
* 다시 훑지 않도록 이 방식을 둡니다. 빠진 메일이 있어도 다음 전체 훑기(최대 1시간 뒤)가 채웁니다.
|
|
*
|
|
* @param {object} env 실행 환경(DB, RESEND_API_KEY 등)
|
|
* @param {object} [options]
|
|
* @param {string} [options.trigger] 기록용 실행 계기: 'startup' | 'timer' | 'manual' | 'poll'
|
|
* @param {boolean} [options.quick] true 면 빠른 확인 방식
|
|
* @returns {Promise<{imported:number, errors:number}|{busy:true}>}
|
|
*/
|
|
export async function syncMailbox(env, { trigger = 'timer', quick = false } = {}) {
|
|
if (env.SYNCING) return { busy: true };
|
|
env.SYNCING = true; let imported = 0; const errors = []; let run = null;
|
|
try {
|
|
// 실행 기록과 메일함 목록 조회도 try 안에 둡니다. 예전에는 밖에 있어서 여기서 예외(예: SQLITE_BUSY)가 나면
|
|
// finally 가 돌지 않아 syncing 이 true 로 남고, 프로세스를 재시작할 때까지 모든 동기화가 busy 로 끝났습니다.
|
|
run = await env.DB.prepare('INSERT INTO sync_runs(trigger,started_at) VALUES(?,?) RETURNING id').bind(trigger, now()).first();
|
|
// known: 이 게이트웨이가 다루는 메일함 주소 목록(기본 메일함 + 계정 메일함). 다른 주소의 메일은 건너뜁니다.
|
|
const known = await knownMailboxes(env);
|
|
// 1.0.0: Resend 를 쓰는 방향만 훑습니다. 수신이 Haraka 면 받은 메일 목록은 보지 않고(Resend 에 없음), 발신이 SMTP 중계면 보낸 메일 목록도 보지 않습니다.
|
|
const directions = ['inbound', 'outbound'].filter((d) => (d === 'inbound' ? inboundMode(env) === 'resend' : outboundMode(env) === 'resend'));
|
|
for (const direction of directions) {
|
|
// stop: 빠른 확인에서 이미 아는 메일을 만나 이 방향의 훑기를 끝낼 때 true.
|
|
const endpoint = direction === 'inbound' ? '/emails/receiving' : '/emails'; let cursor = ''; let stop = false;
|
|
for (let page = 0; page < SYNC_PAGES[direction] && !stop; page++) {
|
|
const list = await resend(env, endpoint + '?limit=50' + (cursor ? '&after=' + encodeURIComponent(cursor) : ''));
|
|
for (const summary of list.data || []) {
|
|
try {
|
|
let r;
|
|
// 미리 거르기는 웹훅과 같은 주소 집합(to·cc·bcc·received_for)으로 합니다. 예전에는 to 만 봐서 우리 메일함이 참조(Cc)에만 있는 메일을 동기화가 영영 가져오지 않았습니다(0.9.0 수정).
|
|
if (direction === 'inbound') { if (!addresses([...(summary.to || []), ...(summary.cc || []), ...(summary.bcc || []), ...(summary.received_for || [])]).some((a) => known.includes(a))) continue; r = await ingestReceived(env, summary.id, { source: 'sync', summary, known }); }
|
|
else { if (!known.includes(address(summary.from))) continue; r = await ingestSent(env, summary.id, { summary, known }); }
|
|
if (r.imported) imported++;
|
|
// 이미 저장했거나 영구 삭제한 메일 = 이전 동기화(또는 웹훅)가 여기까지 확인했다는 표시입니다.
|
|
if (quick && (r.skipped === 'exists' || r.skipped === 'tombstone')) { stop = true; break; }
|
|
} catch (e) { errors.push(summary.id + ':' + e.message); if (['mail_auth_failed', 'mail_forbidden', 'mail_not_configured'].includes(e.message)) throw e; }
|
|
}
|
|
if (stop || !list.has_more || !list.data?.length) break;
|
|
cursor = list.data.at(-1).id;
|
|
}
|
|
}
|
|
await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status=?,detail=? WHERE id=?").bind(now(), imported, errors.length ? 'partial' : 'ok', errors.slice(0, 20).join('\n') || null, run.id).run();
|
|
return { imported, errors: errors.length };
|
|
} catch (e) {
|
|
if (run) await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status='failed',detail=? WHERE id=?").bind(now(), imported, (e.message + (e.providerMessage ? ' ' + e.providerMessage : '')).slice(0, 500), run.id).run();
|
|
throw e;
|
|
} finally { env.SYNCING = false; }
|
|
}
|
|
/**
|
|
* API 요청에서 동기화를 시작하고 끝나기를 기다리지 않고 바로 돌아옵니다(화면은 몇 초 뒤 목록을 다시 읽습니다).
|
|
* @param {object} env
|
|
* @param {string} [trigger] 기록용 실행 계기('manual' = 설정 화면 버튼, 'poll' = 목록 자동 새로 고침)
|
|
* @param {{quick?: boolean}} [options] quick: 빠른 확인 방식(syncMailbox 설명 참고)
|
|
* @returns {{started:true}|{busy:true}}
|
|
*/
|
|
export function startSync(env, trigger = 'manual', { quick = false } = {}) {
|
|
if (env.SYNCING) return { busy: true };
|
|
syncMailbox(env, { trigger, quick }).catch((e) => console.error('메일 동기화 실패', e.message, e.providerMessage || ''));
|
|
return { started: true };
|
|
}
|
|
|
|
/**
|
|
* 주기 정리 작업: 만료된 로그인 세션을 지웁니다.
|
|
*
|
|
* 0.8.0 이전에는 같은 자리에서 "보관 기간(30일)이 지난 메일"도 원본·첨부와 함께 삭제했습니다(enforceRetention).
|
|
* 메일 보관 기한을 없애면서 메일 삭제는 빼고 세션 정리만 남겼습니다. 메일은 사용자가 영구 삭제할 때만 지워집니다.
|
|
*
|
|
* @returns {Promise<{sessions:number}>} 지운 세션 수
|
|
*/
|
|
export async function cleanupSessions(env) {
|
|
const result = await env.DB.prepare('DELETE FROM sessions WHERE expires_at < ?').bind(now()).run();
|
|
return { sessions: result.meta.changes };
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 초안·발송
|
|
export async function saveDraft(env, body, id, user = null) {
|
|
const mailbox = user?.mailbox || mailboxOf(env), fromName = user?.name || senderName(env);
|
|
// 보내는 사람 표시 이름은 formatSender 로 만듭니다(0.9.0): 쉼표·따옴표·괄호가 든 이름("Lee, Director")을 그대로 넣으면 RFC 5322 에 어긋나 발송 서비스가 거부하거나 주소가 깨졌습니다.
|
|
const existing = id ? await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first() : null;
|
|
if (id && !existing) throw new Error('not_found');
|
|
if (existing && (existing.folder !== 'drafts' || existing.state !== 'draft')) throw new Error('mail_locked');
|
|
// 초안은 아직 완성되지 않은 주소("bob", "bob@example" 등)도 그대로 저장합니다. 주소 검사는 발송(sendDraft) 때 합니다.
|
|
// 왜: 예전에는 여기서 422 를 던져 자동 저장이 조용히 실패했고, 창을 닫으면 작성 중인 본문이 통째로 사라졌습니다.
|
|
// 부분 수정(0.9.5): 본문에 없는 키는 기존 초안 값을 유지합니다. 예전에는 PATCH { subject } 처럼 일부만 보내도 받는 사람·참조·본문이 빈 값으로 덮였습니다(화면은 늘 전체를 보내 드러나지 않았음).
|
|
// bcc(0.9.5~): 웹 작성 창의 숨은 참조. 예전에는 IMAP 으로 올린 초안의 Bcc 만 저장·발송됐고 웹에서는 입력할 수 없었습니다.
|
|
const keep = (key, col) => (body[key] !== undefined ? addresses(body[key]).slice(0, 100) : existing ? JSON.parse(existing[col] || '[]') : []);
|
|
const to = keep('to', 'recipients_json'), cc = keep('cc', 'cc_json'), bcc = keep('bcc', 'bcc_json');
|
|
const subject = body.subject !== undefined ? clean(body.subject, 500) : existing ? existing.subject : '', stamp = now();
|
|
const content = body.html != null ? sanitizeHtml(body.html, { limit: STORED_HTML_LIMIT }) : body.text != null || !existing ? sanitizeHtml(textToHtml(body.text || '')) : { html: existing.html, text: existing.text };
|
|
// 답장이면 In-Reply-To / References 를 남겨 스레드와 \Answered 표시를 유지합니다.
|
|
const inReplyTo = clean(body.inReplyTo, 300); const headers = inReplyTo && /^<[^\s<>]+>$/.test(inReplyTo) ? { 'In-Reply-To': inReplyTo, References: inReplyTo } : null;
|
|
if (existing) {
|
|
const updated = await env.DB.prepare("UPDATE messages SET recipients_json=?,cc_json=?,bcc_json=?,subject=?,html=?,text=?,received_at=?,updated_at=?,headers_json=COALESCE(?,headers_json) WHERE id=? AND state='draft' AND folder='drafts'").bind(JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, headers ? JSON.stringify(headers) : null, id).run();
|
|
if (!updated.meta.changes) throw new Error('mail_locked');
|
|
await invalidateRaw(env, id);
|
|
} else {
|
|
id = uid();
|
|
await env.DB.prepare(`INSERT INTO messages(id,direction,folder,sender,recipients_json,cc_json,bcc_json,subject,html,text,source,state,received_at,created_at,updated_at,read_at,mailbox,headers_json) VALUES(?,'outbound','drafts',?,?,?,?,?,?,?,'web','draft',?,?,?,?,?,?)`)
|
|
.bind(id, formatSender(fromName, mailbox), JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, stamp, stamp, mailbox, JSON.stringify(headers || {})).run();
|
|
}
|
|
return id;
|
|
}
|
|
/**
|
|
* 초안의 저장된 원본(.eml)을 버립니다. IMAP APPEND 로 올린 초안은 원본을 그대로 보관하는데, 웹에서 제목·본문·첨부를 고치면
|
|
* 그 원본이 옛 내용으로 남아 IMAP/POP/"원본 내려받기" 가 수정 전 내용을 보여 줬습니다(0.8.16 수정). raw_status 를 'none' 으로 돌리면
|
|
* IMAP 은 저장된 행과 첨부로 메시지를 다시 만들고, 발송 때는 sendDraft 가 새 원본을 저장합니다.
|
|
*/
|
|
async function invalidateRaw(env, id) {
|
|
await env.DB.batch([env.DB.prepare('DELETE FROM message_raw WHERE message_id=?').bind(id), env.DB.prepare("UPDATE messages SET raw_status='none',raw_size=0 WHERE id=? AND raw_status<>'none'").bind(id)]);
|
|
}
|
|
// 초안 첨부의 합계·개수 상한(발송 서비스의 메일 전체 40MB 한도에서 나온 내장값). 첨부 한 개의 상한은 0.9.0 부터 관리자·시스템 설정(limits.attachmentLimit, MIME 기준)을 따릅니다.
|
|
const DRAFT_TOTAL_LIMIT = 20 * 1024 * 1024, DRAFT_FILE_COUNT = 10;
|
|
/** 첨부 한 개의 크기 검사: 파일 바이트를 MIME(base64) 크기로 바꿔 적용 중인 상한과 비교합니다. 넘으면 attachment_too_large 에 limit(MIME 바이트)을 붙여 던집니다. */
|
|
async function checkAttachmentSize(env, bytes) {
|
|
const limit = await attachmentLimit(env);
|
|
if (mimeSize(bytes) > limit) { const e = new Error('attachment_too_large'); e.limit = limit; throw e; }
|
|
}
|
|
export async function addAttachment(env, draftId, { filename, contentType, bytes, inline = false }) {
|
|
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
|
|
if (!draft) throw new Error('mail_locked');
|
|
if (!bytes?.length) throw new Error('attachment_too_large');
|
|
await checkAttachmentSize(env, bytes.length);
|
|
const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first();
|
|
if (current.n >= DRAFT_FILE_COUNT || current.total + bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large');
|
|
const id = uid(), isInline = inline && /^image\//.test(String(contentType || ''));
|
|
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
.bind(id, draftId, clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'attachment', clean(contentType, 120) || 'application/octet-stream', isInline ? `${id}@${mailboxOf(env).split('@')[1]}` : null, isInline ? 'inline' : 'attachment', bytes.length, bytes, now()).run();
|
|
await invalidateRaw(env, draftId);
|
|
return id;
|
|
}
|
|
export async function removeAttachment(env, draftId, attachmentId) {
|
|
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
|
|
if (!draft) throw new Error('mail_locked');
|
|
await env.DB.prepare('DELETE FROM attachments WHERE id=? AND message_id=?').bind(attachmentId, draftId).run();
|
|
await invalidateRaw(env, draftId);
|
|
}
|
|
/**
|
|
* 초안을 발송합니다.
|
|
* @param {object} [options.largeFiles] 대용량 첨부파일(0.9.3)의 발송 조건 { days, downloads }. 초안에 대용량 파일이 있으면 필수이며 관리자 상한 이내여야 합니다(large_file_terms).
|
|
* 발송 직전에 파일마다 링크를 만들어 본문 끝에 "대용량 첨부파일" 블록을 덧붙이고, 발송에 실패하면 링크를 되돌립니다.
|
|
*/
|
|
export async function sendDraft(env, id, { largeFiles = null } = {}) {
|
|
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first();
|
|
if (!mail) throw new Error('not_found');
|
|
if (mail.state === 'sent') return mail;
|
|
if (mail.direction !== 'outbound' || mail.folder !== 'drafts') throw new Error('mail_locked');
|
|
// bcc: IMAP 으로 올린 초안(메일 프로그램이 Bcc 헤더를 남김)의 숨은 참조. 예전에는 저장·발송에서 빠져 받는 사람이 조용히 사라졌습니다(0.8.25 수정).
|
|
// 같은 주소는 한 번만(0.9.9): 받는 사람·참조·숨은 참조를 통틀어 소문자 기준으로 중복을 지웁니다(받는 사람 > 참조 > 숨은 참조 순으로 남김).
|
|
// 예전에는 전체 답장에서 대소문자만 다른 주소가 받는 사람과 참조에 함께 들어가면 Resend 에 그대로 넘어가 같은 사람이 두 통을 받았습니다.
|
|
const seen = new Set(); const uniq = (list) => list.filter((a) => { const k = String(a).toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
|
|
const to = uniq(JSON.parse(mail.recipients_json)), cc = uniq(JSON.parse(mail.cc_json)), bcc = uniq(JSON.parse(mail.bcc_json || '[]'));
|
|
// 받는 사람 주소 검사는 여기서 합니다(초안 저장은 미완성 주소도 허용). 잘못된 주소는 invalid_email, 그 밖의 빈 값은 validation.
|
|
if ([...to, ...cc, ...bcc].some((a) => !isEmail(a))) throw new Error('invalid_email');
|
|
// 대용량 파일이 있으면 발송 조건(기한·횟수)을 먼저 검사합니다. 본문이 비어 있어도 대용량 파일이 있으면 보낼 수 있습니다(링크 블록이 본문이 됨).
|
|
const hasLarge = await checkLargeFileTerms(env, id, largeFiles); // 대용량 파일이 있는지(재시도면 조건 생략 가능)
|
|
if (!to.length || !mail.subject || !mail.text.trim() && !mail.html?.includes('<img') && !hasLarge) throw new Error('validation');
|
|
if (mail.state === 'sending' && Date.parse(mail.send_started_at) > Date.now() - 120000) throw new Error('mail_sending');
|
|
// 제한 검사(0.9.0): 받는 사람 수(예전 20명 고정 → 관리자·시스템 설정), 첨부 한 개 크기, 메일함·전체 용량, 월간 통수. 발송을 시작(claim)하기 전에 검사해
|
|
// 어긋나도 초안이 '발송 실패' 상태가 되지 않고 그대로 고칠 수 있게 합니다. 저장될 원본 크기는 본문 + 첨부의 MIME 크기 + 헤더 여유로 어림합니다.
|
|
const sizes = (await env.DB.prepare('SELECT size FROM attachments WHERE message_id=?').bind(id).all()).results.map((a) => Number(a.size || 0));
|
|
await checkOutbound(env, { mailbox: mail.mailbox || mailboxOf(env), recipients: to.length + cc.length + bcc.length, attachmentSizes: sizes, bytes: String(mail.html || '').length + String(mail.text || '').length + sizes.reduce((n, s) => n + mimeSize(s), 0) + 2000 });
|
|
const key = mail.send_key || 'gw-' + uid(), stamp = now();
|
|
const claim = await env.DB.prepare("UPDATE messages SET state='sending',send_key=?,send_started_at=?,updated_at=? WHERE id=? AND (state IN ('draft','failed') OR (state='sending' AND send_started_at<?))").bind(key, stamp, stamp, id, new Date(Date.now() - 120000).toISOString()).run();
|
|
if (!claim.meta.changes) throw new Error('mail_sending');
|
|
try {
|
|
// 답장·전달 인용문에 들어 있는 "원래 메일의" 인라인 이미지(/api/mail/<원래 id>/attachments/<aid>)를 이 초안의 인라인 첨부로 복사합니다.
|
|
// 왜: 예전에는 초안 자신의 첨부만 cid: 로 바꿔, 인용한 원래 메일의 이미지는 받는 사람에게 상대 주소(깨진 이미지)로 갔습니다.
|
|
// 같은 메일함의 메일만 복사합니다(다른 메일함의 첨부 id 를 끼워 넣어 빼내지 못하게).
|
|
let source = String(mail.html || '');
|
|
for (const [, mid, aid] of [...source.matchAll(/<img\b[^>]*\bsrc="\/api\/mail\/([^"/?]+)\/attachments\/([^"/?]+)(?:\?[^"]*)?"/g)]) {
|
|
const originId = decodeURIComponent(mid), attachmentId = decodeURIComponent(aid); if (originId === id) continue;
|
|
const orig = await env.DB.prepare('SELECT a.filename,a.content_type,a.size,a.bytes,m.mailbox FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=? AND a.message_id=?').bind(attachmentId, originId).first();
|
|
if (!orig?.bytes || orig.mailbox !== mail.mailbox) continue;
|
|
const copyId = uid();
|
|
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,'inline',?,?,?)").bind(copyId, id, orig.filename, orig.content_type, `${copyId}@${mailboxOf(env).split('@')[1]}`, orig.size, orig.bytes, now()).run();
|
|
source = source.split(`/api/mail/${mid}/attachments/${aid}`).join(`/api/mail/${encodeURIComponent(id)}/attachments/${copyId}`);
|
|
}
|
|
if (source !== String(mail.html || '')) await env.DB.prepare('UPDATE messages SET html=? WHERE id=?').bind(source, id).run();
|
|
let files = await attachmentRows(env, id);
|
|
// 편집기가 넣은 인라인 이미지(/api/mail/<id>/attachments/<aid>?inline=1)는 cid: 로 바꿔 보냅니다.
|
|
const used = new Set();
|
|
const html = source.replace(/(<img\b[^>]*\bsrc=")\/api\/mail\/[^"/]+\/attachments\/([^"?]+)(?:\?[^"]*)?("[^>]*>)/g, (m, a, aid, b) => { const f = files.find((x) => x.id === aid && x.content_id); if (!f) return m; used.add(f.id); return `${a}cid:${f.content_id}${b}`; });
|
|
// IMAP 으로 올린 초안(메일 프로그램에 그림을 넣은 경우)은 본문이 처음부터 cid: 를 가리킵니다. 그 첨부도 "쓰는 중" 으로 표시합니다.
|
|
// 예전에는 이 첨부가 고아로 잡혀 지워지고 발송에서도 빠져, 받는 사람에게 깨진 그림이 갔습니다(0.8.16 수정).
|
|
for (const [, cid] of html.matchAll(/<img\b[^>]*\bsrc="cid:([^"]+)"/g)) { const f = files.find((x) => x.content_id && x.content_id === cid.replace(/&/g, '&')); if (f) used.add(f.id); }
|
|
// 본문에서 지운 인라인 이미지는 보내지 않고 초안에서도 지웁니다. 첨부 칩 목록에는 인라인 파일이 보이지 않아
|
|
// 사용자가 직접 지울 방법이 없는데, 예전에는 이런 이미지가 일반 첨부로 함께 나갔습니다.
|
|
// Content-ID 가 없는 'inline' 첨부(Apple Mail 등이 PDF·문서를 inline; filename= 으로 올림)는 본문이 가리킬 수 없으므로 고아가 아니라 일반 첨부입니다.
|
|
// 예전에는 이런 첨부가 고아로 잡혀 조용히 지워지고 발송에서도 빠졌습니다(0.8.25 수정).
|
|
const orphans = files.filter((a) => a.disposition === 'inline' && a.content_id && !used.has(a.id));
|
|
for (const a of orphans) await env.DB.prepare('DELETE FROM attachments WHERE id=?').bind(a.id).run();
|
|
files = files.filter((a) => !orphans.includes(a));
|
|
// 보내는 주소는 항상 초안이 속한 메일함입니다. 저장된 sender 는 표시 이름을 쓰기 위해서만 보고, 주소가 메일함과 다르면 버립니다.
|
|
// 왜: IMAP APPEND 로 올린 초안은 From 헤더를 그대로 sender 에 저장하므로, 예전에는 일반 계정이 From 을 다른 주소(예: 관리자 메일함)로
|
|
// 적은 초안을 올린 뒤 웹에서 보내기를 누르면 그 주소로 발송됐습니다(SMTP 제출은 allowedSenders 로 막고 있었음). 0.8.16 수정.
|
|
const mailbox = mail.mailbox || mailboxOf(env);
|
|
const sender = address(mail.sender) === mailbox ? formatSender(nameOf(mail.sender), mailbox) : formatSender(senderName(env), mailbox);
|
|
// 발송에 넘기는 사용자 지정 헤더는 답장 스레드용 두 개뿐입니다. IMAP 으로 올린 초안(appendMessage)은 원본의 모든 헤더
|
|
// (Content-Type, MIME-Version, Date, Message-ID …)를 headers_json 에 갖고 있어, 그대로 보내면 발송 서비스가 거부하거나 MIME 이 깨집니다.
|
|
const savedHeaders = JSON.parse(mail.headers_json || '{}'); const headers = {};
|
|
for (const name of ['In-Reply-To', 'References']) { const k = Object.keys(savedHeaders).find((x) => x.toLowerCase() === name.toLowerCase()); if (k && savedHeaders[k]) headers[name] = String(savedHeaders[k]); }
|
|
// 대용량 첨부파일(0.9.3): 링크·기한·횟수를 확정하고 본문(HTML·텍스트) 끝에 블록을 덧붙입니다. 저장본에도 같은 본문을 남겨 보낸 메일함에서 링크를 볼 수 있게 합니다.
|
|
// 링크의 호스트(1.0.8): 경로 접두사 아래의 웹 메일이면 "호스트/mail" 처럼 접두사까지 붙여 https://host/mail/dl/… 이 됩니다.
|
|
const large = await finalizeLargeFiles(env, id, largeFiles, (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), env.STRINGS || {});
|
|
const finalHtml = html + large.html, finalText = String(mail.text || '') + large.text;
|
|
const payload = { from: sender, to, ...(cc.length ? { cc } : {}), ...(bcc.length ? { bcc } : {}), subject: mail.subject, html: finalHtml, text: finalText, ...(Object.keys(headers).length ? { headers } : {}) };
|
|
if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(used.has(a.id) ? { content_id: a.content_id } : {}) }));
|
|
// 발송 모듈은 설정(outbound)에 따라 Resend 또는 SMTP 중계(내장 Haraka·외부 서버)입니다(1.0.0, providers/index.mjs).
|
|
const result = await sendMail(env, payload, key);
|
|
const sentAt = now();
|
|
// 발송이 끝나면 보낸 메일함으로 옮깁니다. 보관 기한이 없으므로 expires_at 은 채우지 않습니다.
|
|
// 저장본 html 은 인라인 이미지 복사를 반영한 source(위에서 갱신) 기준입니다. 0.9.3 은 복사 전 값(mail.html)을 다시 써서 원본 메일을 지우면 보낸 메일의 그림이 깨졌습니다(0.9.5 수정).
|
|
await env.DB.prepare("UPDATE messages SET state='sent',folder='sent',provider_id=?,sent_at=?,received_at=?,updated_at=?,read_at=COALESCE(read_at,?),original_html=?,html=?,text=? WHERE id=?").bind(result.id, sentAt, sentAt, sentAt, sentAt, finalHtml, source + large.html, finalText, id).run();
|
|
const stored = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first();
|
|
await storeRaw(env, id, await buildStoredEml(env, stored), 'built');
|
|
return stored;
|
|
} catch (e) {
|
|
await env.DB.prepare("UPDATE messages SET state='failed',updated_at=? WHERE id=? AND state='sending'").bind(now(), id).run();
|
|
// 대용량 파일의 링크는 되돌리지 않습니다: 다시 보낼 때 같은 토큰·같은 본문으로 보내야 멱등 키가 맞고, 첫 시도가 실제로 전달됐을 때 링크가 살아 있습니다(0.9.5).
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 메일 한 통의 IMAP 영구 플래그(\Flagged 중요, \Answered 답장함 등)를 켜거나 끕니다.
|
|
* 웹 API(별표·답장 표시)와 SMTP 제출(답장 발송 시 원래 메일에 \Answered) 이 함께 쓰도록 여기에 둡니다.
|
|
* @param {string} id 메일 id
|
|
* @param {string} flag 플래그 이름(예: '\Flagged')
|
|
* @param {boolean} on true 면 켜고 false 면 끕니다
|
|
*/
|
|
export async function setFlag(env, id, flag, on) {
|
|
const row = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first();
|
|
const flags = new Set(JSON.parse(row?.flags || '[]')); if (on) flags.add(flag); else flags.delete(flag);
|
|
await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(id, JSON.stringify([...flags]), now()).run();
|
|
}
|
|
|
|
// ------------------------------------------------------------------ IMAP APPEND / COPY
|
|
/**
|
|
* 클라이언트가 올린 원본(.eml)을 메일함에 저장합니다(보낸 메일 복사본·초안 등). 원본은 그대로 보관합니다.
|
|
* @param {boolean} [options.enforceQuota] 메일함 최대 크기(0.9.0)를 검사할지. IMAP APPEND 는 검사하고(넘으면 quota_exceeded),
|
|
* SMTP 제출은 발송 전에 이미 검사했으므로 false 로 넘깁니다(발송된 메일의 보낸 메일함 저장이 실패하면 안 되므로).
|
|
*/
|
|
export async function appendMessage(env, folder, eml, { flags = [], internalDate = null, mailbox = null, enforceQuota = true } = {}) {
|
|
if (!['inbox', 'sent', 'drafts', 'trash'].includes(folder)) throw new Error('not_found');
|
|
// 줄 끝을 CRLF 로 맞춘 뒤 해석합니다. 일부 프로그램·스크립트는 LF 만 쓴 메시지를 올리는데, 해석기는 CRLF 경계만 알아
|
|
// 예전에는 본문이 빈 메일로 저장됐습니다(0.8.16 수정). 원본도 정규화한 바이트로 보관해 IMAP/POP 이 같은 것을 돌려줍니다.
|
|
eml = normalizeCrlf(eml);
|
|
const parsed = parseMime(eml), info = summarize(parsed);
|
|
// 보낸 메일함 중복 방지: 메일 프로그램은 SMTP 로 보낸 뒤 같은 메일을 IMAP 으로 보낸 메일함에 다시 올립니다(APPEND).
|
|
// SMTP 제출(smtp.mjs)이 이미 같은 Message-ID 로 저장했다면 새로 만들지 않고 그 메일을 돌려줍니다(반대 순서는 smtp.mjs 가 처리).
|
|
// 용량 검사보다 먼저 합니다: 발송으로 메일함이 가득 찬 직후 올라오는 사본이 OVERQUOTA 로 거부되면 메일 프로그램이 "보낸 편지함 복사 실패" 를 반복했습니다(0.9.3).
|
|
if (folder === 'sent' && info.messageId) {
|
|
const same = await env.DB.prepare("SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=?").bind(mailbox || mailboxOf(env), info.messageId).first();
|
|
if (same) return { id: same.id, duplicate: true };
|
|
}
|
|
if (enforceQuota) {
|
|
const box = mailbox || mailboxOf(env), quota = await mailboxQuota(env, box);
|
|
if (quota > 0 && (await mailboxUsage(env, box)) + eml.length > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; }
|
|
// 전체 저장 용량(시스템)도 봅니다. 예전에는 APPEND 만 이 제한을 비켜 가 메일 프로그램으로 저장소를 상한 너머로 키울 수 있었습니다(0.9.3).
|
|
const sys = systemLimits(env);
|
|
if (sys.totalBytes > 0 && (await totalUsage(env)) + eml.length > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; }
|
|
}
|
|
const content = safeContent(info.html, info.text);
|
|
const receivedAt = internalDate || (Date.parse(info.date) ? new Date(Date.parse(info.date)).toISOString() : now()), stamp = now(), id = uid();
|
|
mailbox = mailbox || mailboxOf(env); const inbound = folder === 'inbox' || (folder === 'trash' && address(info.from) !== mailbox);
|
|
const direction = inbound ? 'inbound' : 'outbound', state = folder === 'drafts' ? 'draft' : inbound ? 'received' : 'sent';
|
|
const seen = flags.some((f) => f.toLowerCase() === '\\seen') || !inbound;
|
|
// bcc_json: 원본의 Bcc 헤더(메일 프로그램이 초안에 남겨 둠). 발송(sendDraft)이 숨은 참조로 씁니다(0.8.25~).
|
|
await env.DB.prepare(`INSERT INTO messages(id,message_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'web',?,?,?,?,?,?,?)`)
|
|
.bind(id, info.messageId || null, direction, folder, info.from, JSON.stringify(info.to), JSON.stringify(info.cc), JSON.stringify(info.bcc || []), JSON.stringify(info.replyTo), clean(info.subject, 500), content.html, info.text || content.text, info.html || null, JSON.stringify(info.headers), state, receivedAt, stamp, stamp, inbound ? null : receivedAt, seen ? stamp : null, mailbox).run();
|
|
for (const a of info.attachments) await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)").bind(uid(), id, clean(a.filename, 255) || 'attachment', clean(a.contentType, 120), a.contentId, a.disposition, a.bytes.length, a.bytes, stamp).run();
|
|
const persisted = flags.filter((f) => ['\\Flagged', '\\Answered', '\\Deleted'].some((x) => x.toLowerCase() === f.toLowerCase())).map((f) => f[0] + f[1].toUpperCase() + f.slice(2).toLowerCase());
|
|
if (persisted.length) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(id, JSON.stringify(persisted), stamp).run();
|
|
await storeRaw(env, id, Buffer.from(eml), 'stored');
|
|
return { id };
|
|
}
|
|
/** IMAP COPY: 메시지·원본·첨부를 다른 메일함에 복제합니다. */
|
|
/**
|
|
* 다른 메일의 첨부(일반 첨부만)를 초안으로 복사합니다(0.8.25~). 웹의 "전달" 이 원본 첨부를 함께 보내기 위한 것으로,
|
|
* 예전에는 전달 메일에 인용 본문의 그림만 따라가고 PDF 같은 일반 첨부는 조용히 빠졌습니다.
|
|
* 초안 상태의 메일에만, 초안의 첨부 개수·용량 한도(DRAFT_FILE_COUNT / DRAFT_TOTAL_LIMIT) 안에서 복사합니다. 원본이 같은 메일함인지는 API 가 확인합니다.
|
|
* @returns {Promise<string[]>} 새 첨부 id 목록
|
|
*/
|
|
export async function copyAttachments(env, draftId, originId, ids) {
|
|
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
|
|
if (!draft) throw new Error('mail_locked');
|
|
const out = [];
|
|
for (const aid of ids) {
|
|
// content_id 가 없는 inline(예전 규칙으로 저장된 행)도 파일 첨부로 보고 복사합니다(0.9.9).
|
|
const a = await env.DB.prepare("SELECT filename,content_type,size,bytes FROM attachments WHERE id=? AND message_id=? AND (disposition='attachment' OR content_id IS NULL)").bind(aid, originId).first();
|
|
if (!a?.bytes) continue;
|
|
await checkAttachmentSize(env, a.bytes.length);
|
|
const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first();
|
|
if (current.n >= DRAFT_FILE_COUNT || current.total + a.bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large');
|
|
const id = uid();
|
|
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,NULL,'attachment',?,?,?)").bind(id, draftId, a.filename, a.content_type, a.bytes.length, a.bytes, now()).run();
|
|
out.push(id);
|
|
}
|
|
if (out.length) await invalidateRaw(env, draftId);
|
|
return out;
|
|
}
|
|
export async function copyMessage(env, id, folder) {
|
|
const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!m) throw new Error('not_found');
|
|
const newId = uid(), stamp = now();
|
|
// 복제할 열 목록. expires_at 은 더 쓰지 않으므로 빼서 복사본은 항상 NULL 이 됩니다.
|
|
const cols = ['message_id','direction','sender','recipients_json','cc_json','bcc_json','reply_to_json','subject','html','text','original_html','headers_json','source','state','received_at','sent_at','read_at','raw_status','raw_size','mailbox'];
|
|
await env.DB.prepare(`INSERT INTO messages(id,folder,created_at,updated_at,provider_id,${cols.join(',')}) VALUES(?,?,?,?,NULL,${cols.map(() => '?').join(',')})`).bind(newId, folder, stamp, stamp, ...cols.map((c) => m[c] ?? null)).run();
|
|
await env.DB.prepare('INSERT INTO message_raw(message_id,eml,stored_at) SELECT ?,eml,? FROM message_raw WHERE message_id=?').bind(newId, stamp, id).run();
|
|
const files = (await env.DB.prepare('SELECT * FROM attachments WHERE message_id=?').bind(id).all()).results;
|
|
for (const a of files) await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)').bind(uid(), newId, a.provider_id, a.filename, a.content_type, a.content_id, a.disposition, a.size, a.bytes, stamp).run();
|
|
const f = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first();
|
|
if (f) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(newId, f.flags, stamp).run();
|
|
return newId;
|
|
}
|