generated from ttipo/project-base
b0412ac453
계약 2절 9단계. 보내기 취소 · 예약은 엔진 제출의 HOLDFOR · HOLDUNTIL 로 붙잡고(7일 안), 취소는 undoStatus: canceled 뒤 그 메일을 임시보관함의 초안으로 되돌린다. 외부 이미지는 서버가 대신 받아 온다 — 모든 주소가 공인일 때만 푼 주소로 고정 연결 · 80/443 · 다시 가기 3번 · 10초 · 5MB · 앞 바이트로 그림 형식, 중계 주소의 표는 AES-GCM 으로 암호화. 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞, 설정은 DB 표 mail_settings(마이그레이션 AddMailSettings). 리뷰로 고친 것: - 붙잡힌 메일을 옮기거나 지워도 엔진은 시간이 되면 보낸다 — 한 통 · 여러 통 PATCH · DELETE · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고 막는다(MAIL_015 · failedIds · skippedIds). 읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않는다(MAIL_001), 보낼 시각을 읽지 못한 기다리는 제출도 붙잡힌 것으로 본다. - 발송은 늘 envelope 을 직접 준다 — 엔진이 오타 도메인 같은 주소를 말없이 빼고 보냈다. 거절한 주소를 not-found 로 빼고 다시 제출한다 (한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지), 같은 키 재시도는 제출의 envelope 으로 같은 답을 다시 만든다. - 이미지 중계: 서버 16 · 직원 6 동시 수(받는 수가 적은 직원부터), 자리는 응답이 끝날 때까지, 버퍼는 한 번에 — 메모리가 동시 수 × 5MB 를 넘지 않게. - 다시 취소해도 200(취소 표시가 있으면 옮기지 않는다), 엔진이 확인한 취소만 취소로 본다, 모두 거절된 붙잡은 발송은 취소한 뒤 사본을 지운다. - 차단 주소는 dot-atom 만, 이미 있는 주소를 POST 하면 꺼진 규칙을 켠다, 로그의 중계 주소 가리기는 대소문자 무시. - 연결 · 이름 풀기를 실제 소켓으로 시험한다(테스트 전용 포트 · DNS 서버 — 앱은 80/443 고정). 에러 코드 MAIL_014(이미 보냄) · MAIL_015(붙잡힌 메일)를 더하고 MAIL_013 문구에 차단 목록을 넣었다(뜻은 그대로). 클라이언트 사전을 생성했다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
/**
|
|
* 서명 그림 테스트용 그림 파일 머리 — 테스트 전용 도우미 (앱 코드에서 import 하지 않는다).
|
|
*
|
|
* 형식 확인(signature-image-format.ts)은 파일 앞부분만 본다 — 여기서는 그 앞부분과, 크기를 맞출 채움 바이트만 만든다
|
|
* (실제로 그릴 수 있는 그림일 필요는 없다).
|
|
*
|
|
* 위치: src 밖(test/) — cpu-time.ts 와 같은 이유로 빌드·커버리지에 들어가지 않는다.
|
|
* 단위 테스트는 `@/../test/image-fixtures`, e2e 는 `./image-fixtures` 로 부른다.
|
|
*/
|
|
|
|
/** PNG 서명 + IHDR(가로·세로·비트 깊이·색 형식) — CRC 는 보지 않으므로 비워 둔다. `size` 바이트가 되게 0 으로 채운다 */
|
|
export function pngBytes(width: number, height: number, size = 33): Buffer {
|
|
const data = Buffer.alloc(Math.max(size, 33));
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(data);
|
|
data.writeUInt32BE(13, 8);
|
|
data.write('IHDR', 12, 'ascii');
|
|
data.writeUInt32BE(width, 16);
|
|
data.writeUInt32BE(height, 20);
|
|
data[24] = 8; // 비트 깊이
|
|
data[25] = 6; // 색 형식 RGBA
|
|
return data;
|
|
}
|
|
|
|
/** GIF 머리 (논리 화면의 가로·세로, 리틀 엔디언) */
|
|
export function gifBytes(
|
|
width: number,
|
|
height: number,
|
|
version = 'GIF89a',
|
|
): Buffer {
|
|
const data = Buffer.alloc(13);
|
|
data.write(version, 0, 'ascii');
|
|
data.writeUInt16LE(width, 6);
|
|
data.writeUInt16LE(height, 8);
|
|
return data;
|
|
}
|
|
|
|
/** SOI + APP0(JFIF) + (선택) 표지 앞 채움 바이트 + SOFn(세로·가로) + EOI */
|
|
export function jpegBytes(
|
|
width: number,
|
|
height: number,
|
|
options: { sof?: number; fill?: boolean } = {},
|
|
): Buffer {
|
|
const app0 = Buffer.from([
|
|
0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00,
|
|
0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
|
]);
|
|
const sof = Buffer.from([
|
|
0xff,
|
|
options.sof ?? 0xc0,
|
|
0x00,
|
|
0x11,
|
|
0x08,
|
|
(height >> 8) & 0xff,
|
|
height & 0xff,
|
|
(width >> 8) & 0xff,
|
|
width & 0xff,
|
|
0x03,
|
|
0x01,
|
|
0x22,
|
|
0x00,
|
|
0x02,
|
|
0x11,
|
|
0x01,
|
|
0x03,
|
|
0x11,
|
|
0x01,
|
|
]);
|
|
return Buffer.concat([
|
|
Buffer.from([0xff, 0xd8]),
|
|
app0,
|
|
...(options.fill ? [Buffer.from([0xff, 0xff])] : []),
|
|
sof,
|
|
Buffer.from([0xff, 0xd9]),
|
|
]);
|
|
}
|
|
|
|
/** WebP 머리 — 'RIFF' 크기 'WEBP' 첫 덩어리(VP8 · VP8L · VP8X). `size` 바이트가 되게 0 으로 채운다 (바깥 이미지 중계 — 9단계) */
|
|
export function webpBytes(chunk = 'VP8 ', size = 30): Buffer {
|
|
const data = Buffer.alloc(Math.max(size, 20));
|
|
data.write('RIFF', 0, 'ascii');
|
|
data.writeUInt32LE(data.length - 8, 4);
|
|
data.write('WEBP', 8, 'ascii');
|
|
data.write(chunk, 12, 'ascii');
|
|
return data;
|
|
}
|