mirror of
https://github.com/mskatoni/ni-mail.git
synced 2026-09-09 09:26:44 +08:00
Add files via upload
This commit is contained in:
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
declare module "postal-mime" {
|
||||
export interface PostalMimeAttachment {
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
content?: ArrayBuffer | Uint8Array | string | null;
|
||||
contentId?: string | null;
|
||||
disposition?: string | null;
|
||||
}
|
||||
|
||||
export interface PostalMimeResult {
|
||||
subject?: string | null;
|
||||
text?: string | null;
|
||||
html?: string | null;
|
||||
attachments?: PostalMimeAttachment[] | null;
|
||||
}
|
||||
|
||||
export default class PostalMime {
|
||||
constructor(options?: Record<string, unknown>);
|
||||
parse(input: ArrayBuffer | Uint8Array | string): Promise<PostalMimeResult>;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "cloudflare:workers" {
|
||||
export class DurableObject {
|
||||
constructor(state: any, env: any);
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import PostalMime from "postal-mime";
|
||||
import type { Env, IncomingEmailPayload, StoredAttachmentInput } from "./types";
|
||||
import { MailboxDO } from "./mailbox-do";
|
||||
import {
|
||||
assertAuthorized,
|
||||
badRequest,
|
||||
buildSnippet,
|
||||
isAllowedMailbox,
|
||||
json,
|
||||
mailboxStub,
|
||||
normalizeEmail,
|
||||
normalizeMessageId,
|
||||
notFound,
|
||||
parseJson,
|
||||
resolveMailbox,
|
||||
serverError,
|
||||
toBase64,
|
||||
unauthorized,
|
||||
} from "./utils";
|
||||
|
||||
export { MailboxDO };
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/health") {
|
||||
return json({ ok: true, service: "ni-mail" });
|
||||
}
|
||||
|
||||
if (!assertAuthorized(request, env)) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
if (request.method === "GET" && url.pathname === "/latest") {
|
||||
const mailbox = resolveMailbox(request, env);
|
||||
if (!mailbox) return badRequest("mailbox is required");
|
||||
return proxyToMailbox(env, mailbox, `/internal/emails/latest?folder=inbox`);
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/mails") {
|
||||
const mailbox = resolveMailbox(request, env);
|
||||
if (!mailbox) return badRequest("mailbox is required");
|
||||
const search = new URLSearchParams(url.search);
|
||||
if (!search.has("folder")) search.set("folder", "inbox");
|
||||
return proxyToMailbox(env, mailbox, `/internal/emails?${search.toString()}`);
|
||||
}
|
||||
|
||||
const legacyMailMatch = url.pathname.match(/^\/mail\/([^/]+)$/);
|
||||
if (request.method === "GET" && legacyMailMatch) {
|
||||
const mailbox = resolveMailbox(request, env);
|
||||
if (!mailbox) return badRequest("mailbox is required");
|
||||
return proxyToMailbox(env, mailbox, `/internal/emails/${encodeURIComponent(decodeURIComponent(legacyMailMatch[1]!))}`);
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && url.pathname === "/mails") {
|
||||
const mailbox = resolveMailbox(request, env);
|
||||
if (!mailbox) return badRequest("mailbox is required");
|
||||
const folder = url.searchParams.get("folder") ?? "inbox";
|
||||
return proxyToMailbox(env, mailbox, `/internal/inbox?folder=${encodeURIComponent(folder)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
const mailboxBase = url.pathname.match(/^\/api\/mailboxes\/([^/]+)(\/.*)?$/);
|
||||
if (mailboxBase) {
|
||||
const mailboxId = normalizeEmail(decodeURIComponent(mailboxBase[1]!));
|
||||
if (!isAllowedMailbox(mailboxId, env)) {
|
||||
return badRequest(`mailbox domain is not allowed: ${mailboxId}`);
|
||||
}
|
||||
|
||||
const subPath = mailboxBase[2] ?? "";
|
||||
|
||||
if (request.method === "GET" && subPath === "/latest") {
|
||||
const folder = url.searchParams.get("folder") ?? "inbox";
|
||||
return proxyToMailbox(env, mailboxId, `/internal/emails/latest?folder=${encodeURIComponent(folder)}`);
|
||||
}
|
||||
|
||||
if (request.method === "GET" && subPath === "/emails") {
|
||||
const search = new URLSearchParams(url.search);
|
||||
if (!search.has("folder")) search.set("folder", "inbox");
|
||||
return proxyToMailbox(env, mailboxId, `/internal/emails?${search.toString()}`);
|
||||
}
|
||||
|
||||
const emailMatch = subPath.match(/^\/emails\/([^/]+)$/);
|
||||
if (request.method === "GET" && emailMatch) {
|
||||
return proxyToMailbox(env, mailboxId, `/internal/emails/${encodeURIComponent(decodeURIComponent(emailMatch[1]!))}`);
|
||||
}
|
||||
|
||||
const readMatch = subPath.match(/^\/emails\/([^/]+)\/read$/);
|
||||
if (request.method === "POST" && readMatch) {
|
||||
return proxyToMailbox(env, mailboxId, `/internal/emails/${encodeURIComponent(decodeURIComponent(readMatch[1]!))}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
const threadMatch = subPath.match(/^\/threads\/([^/]+)$/);
|
||||
if (request.method === "GET" && threadMatch) {
|
||||
return proxyToMailbox(env, mailboxId, `/internal/threads/${encodeURIComponent(decodeURIComponent(threadMatch[1]!))}`);
|
||||
}
|
||||
|
||||
if (request.method === "POST" && subPath === "/send") {
|
||||
const body = await parseJson<Record<string, unknown>>(request);
|
||||
return proxyToMailbox(env, mailboxId, `/internal/send`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...body, mailboxId }),
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "POST" && subPath === "/reply") {
|
||||
const body = await parseJson<Record<string, unknown>>(request);
|
||||
return proxyToMailbox(env, mailboxId, `/internal/reply`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...body, mailboxId }),
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "POST" && subPath === "/forward") {
|
||||
const body = await parseJson<Record<string, unknown>>(request);
|
||||
return proxyToMailbox(env, mailboxId, `/internal/forward`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...body, mailboxId }),
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && subPath === "/emails") {
|
||||
const folder = url.searchParams.get("folder") ?? "inbox";
|
||||
return proxyToMailbox(env, mailboxId, `/internal/inbox?folder=${encodeURIComponent(folder)}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentMatch = url.pathname.match(/^\/api\/attachments\/([^/]+)$/);
|
||||
if (request.method === "GET" && attachmentMatch) {
|
||||
const mailbox = resolveMailbox(request, env);
|
||||
if (!mailbox) return badRequest("mailbox is required");
|
||||
return proxyToMailbox(env, mailbox, `/internal/attachments/${encodeURIComponent(decodeURIComponent(attachmentMatch[1]!))}`);
|
||||
}
|
||||
|
||||
return notFound("unknown route");
|
||||
} catch (error) {
|
||||
return serverError("request failed", serializeError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async email(message: ForwardableEmailMessageLike, env: Env): Promise<void> {
|
||||
const mailbox = normalizeEmail(message.to);
|
||||
if (!isAllowedMailbox(mailbox, env)) {
|
||||
message.setReject(`mailbox domain not allowed: ${mailbox}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parser = new PostalMime();
|
||||
const raw = await new Response(message.raw).arrayBuffer();
|
||||
const parsed = await parser.parse(raw);
|
||||
const headers = Object.fromEntries(Array.from(message.headers.entries()).map(([key, value]) => [key.toLowerCase(), value]));
|
||||
const subject = parsed.subject ?? "(no subject)";
|
||||
const bodyText = parsed.text ?? "";
|
||||
const bodyHtml = parsed.html ?? "";
|
||||
const attachments: StoredAttachmentInput[] = (parsed.attachments ?? []).map((attachment) => ({
|
||||
filename: attachment.filename ?? "attachment.bin",
|
||||
type: attachment.mimeType ?? "application/octet-stream",
|
||||
disposition: attachment.disposition === "inline" ? "inline" : "attachment",
|
||||
contentId: attachment.contentId ?? undefined,
|
||||
contentBase64: toBase64(attachment.content ?? new Uint8Array()),
|
||||
}));
|
||||
|
||||
const payload: IncomingEmailPayload = {
|
||||
id: crypto.randomUUID(),
|
||||
folderId: "inbox",
|
||||
from: normalizeEmail(message.from),
|
||||
to: mailbox,
|
||||
cc: headers["cc"] ?? null,
|
||||
bcc: headers["bcc"] ?? null,
|
||||
subject,
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
snippet: buildSnippet(bodyText, bodyHtml),
|
||||
date: headers["date"] ? new Date(headers["date"]).toISOString() : new Date().toISOString(),
|
||||
messageId: normalizeMessageId(headers["message-id"] ?? `${crypto.randomUUID()}@${mailbox.split("@")[1] ?? "localhost"}`),
|
||||
inReplyTo: normalizeMessageId(headers["in-reply-to"] ?? ""),
|
||||
references: headers["references"] ?? null,
|
||||
rawHeaders: headers,
|
||||
attachments,
|
||||
};
|
||||
|
||||
const response = await proxyToMailbox(env, mailbox, `/internal/incoming`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const textBody = await response.text();
|
||||
throw new Error(`mailbox storage failed: ${textBody}`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.setReject(`worker failed to process email: ${stringifyError(error)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
type ForwardableEmailMessageLike = {
|
||||
from: string;
|
||||
to: string;
|
||||
headers: Headers;
|
||||
raw: ReadableStream<Uint8Array>;
|
||||
setReject(reason: string): void;
|
||||
};
|
||||
|
||||
async function proxyToMailbox(env: Env, mailboxId: string, path: string, init?: RequestInit): Promise<Response> {
|
||||
const stub = mailboxStub(env, mailboxId);
|
||||
const request = new Request(`https://mailbox.internal${path}`, init);
|
||||
return await stub.fetch(request);
|
||||
}
|
||||
|
||||
function serializeError(error: unknown): Record<string, unknown> {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
+1001
File diff suppressed because it is too large
Load Diff
+155
@@ -0,0 +1,155 @@
|
||||
export type SqlCursorRow = object;
|
||||
|
||||
export interface SqlStorage {
|
||||
exec<T extends SqlCursorRow = SqlCursorRow>(query: string, ...bindings: unknown[]): Iterable<T>;
|
||||
}
|
||||
|
||||
export interface DurableObjectStateLike {
|
||||
storage: {
|
||||
sql: SqlStorage;
|
||||
};
|
||||
blockConcurrencyWhile<T>(callback: () => Promise<T> | T): Promise<T>;
|
||||
}
|
||||
|
||||
export interface DurableObjectIdLike {
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export interface DurableObjectStubLike {
|
||||
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
|
||||
export interface DurableObjectNamespaceLike<T = unknown> {
|
||||
idFromName(name: string): DurableObjectIdLike;
|
||||
get(id: DurableObjectIdLike): DurableObjectStubLike;
|
||||
}
|
||||
|
||||
export interface R2ObjectBodyLike {
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
size: number;
|
||||
httpMetadata?: {
|
||||
contentType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface R2BucketLike {
|
||||
put(
|
||||
key: string,
|
||||
value: ArrayBuffer | Uint8Array | string,
|
||||
options?: {
|
||||
httpMetadata?: {
|
||||
contentType?: string;
|
||||
};
|
||||
customMetadata?: Record<string, string>;
|
||||
},
|
||||
): Promise<void>;
|
||||
get(key: string): Promise<R2ObjectBodyLike | null>;
|
||||
delete(keys: string | string[]): Promise<void>;
|
||||
}
|
||||
|
||||
export interface EmailAttachment {
|
||||
content: string | ArrayBuffer;
|
||||
filename: string;
|
||||
type: string;
|
||||
disposition: "attachment" | "inline";
|
||||
contentId?: string;
|
||||
}
|
||||
|
||||
export interface EmailSendMessage {
|
||||
to: string | string[];
|
||||
from: string | { email: string; name: string };
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
replyTo?: string | { email: string; name: string };
|
||||
attachments?: EmailAttachment[];
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface EmailBindingLike {
|
||||
send(message: EmailSendMessage): Promise<{ messageId: string }>;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
AUTH_KEY: string;
|
||||
DOMAINS: string;
|
||||
DEFAULT_MAILBOX?: string;
|
||||
MAILBOX: DurableObjectNamespaceLike;
|
||||
BUCKET: R2BucketLike;
|
||||
EMAIL?: EmailBindingLike;
|
||||
}
|
||||
|
||||
export interface StoredAttachmentInput {
|
||||
filename: string;
|
||||
type: string;
|
||||
disposition?: "attachment" | "inline";
|
||||
contentId?: string;
|
||||
contentBase64: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface StoredAttachmentRecord {
|
||||
id: string;
|
||||
email_id: string;
|
||||
r2_key: string;
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
content_id: string | null;
|
||||
disposition: string | null;
|
||||
}
|
||||
|
||||
export interface IncomingEmailPayload {
|
||||
id: string;
|
||||
folderId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
cc?: string | null;
|
||||
bcc?: string | null;
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
bodyHtml: string;
|
||||
snippet: string;
|
||||
date: string;
|
||||
messageId: string;
|
||||
inReplyTo?: string | null;
|
||||
references?: string | null;
|
||||
rawHeaders: Record<string, string>;
|
||||
attachments: StoredAttachmentInput[];
|
||||
}
|
||||
|
||||
export interface SendRequestBody {
|
||||
fromName?: string;
|
||||
to: string | string[];
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
replyTo?: string;
|
||||
attachments?: StoredAttachmentInput[];
|
||||
}
|
||||
|
||||
export interface ReplyRequestBody {
|
||||
originalEmailId: string;
|
||||
fromName?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
replyAll?: boolean;
|
||||
attachments?: StoredAttachmentInput[];
|
||||
}
|
||||
|
||||
export interface ForwardRequestBody {
|
||||
originalEmailId: string;
|
||||
to: string | string[];
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
fromName?: string;
|
||||
subject?: string;
|
||||
introText?: string;
|
||||
introHtml?: string;
|
||||
includeOriginalAttachments?: boolean;
|
||||
attachments?: StoredAttachmentInput[];
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import type { DurableObjectStubLike, Env, StoredAttachmentInput } from "./types";
|
||||
|
||||
const REPLY_PREFIX_RE = /^(\s*(?:re|fw|fwd|aw|wg|sv|réf)\s*:\s*)+/i;
|
||||
const EMAIL_RE = /([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+)/g;
|
||||
|
||||
export function json(data: unknown, status = 200, headers?: HeadersInit): Response {
|
||||
return new Response(JSON.stringify(data, null, 2), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...(headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function text(data: string, status = 200, headers?: HeadersInit): Response {
|
||||
return new Response(data, { status, headers });
|
||||
}
|
||||
|
||||
export function badRequest(message: string, details?: unknown): Response {
|
||||
return json({ error: message, details }, 400);
|
||||
}
|
||||
|
||||
export function unauthorized(): Response {
|
||||
return json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
export function notFound(message = "not found"): Response {
|
||||
return json({ error: message }, 404);
|
||||
}
|
||||
|
||||
export function serverError(message: string, details?: unknown): Response {
|
||||
return json({ error: message, details }, 500);
|
||||
}
|
||||
|
||||
export function normalizeEmail(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function mailboxStub(env: Env, mailboxId: string): DurableObjectStubLike {
|
||||
const id = env.MAILBOX.idFromName(normalizeEmail(mailboxId));
|
||||
return env.MAILBOX.get(id);
|
||||
}
|
||||
|
||||
export function assertAuthorized(request: Request, env: Env): boolean {
|
||||
const incoming = request.headers.get("x-auth-key") ?? "";
|
||||
return Boolean(env.AUTH_KEY) && incoming === env.AUTH_KEY;
|
||||
}
|
||||
|
||||
export function hasOutboundEmailBinding(env: Env): boolean {
|
||||
return Boolean(env.EMAIL && typeof env.EMAIL.send === "function");
|
||||
}
|
||||
|
||||
export function resolveMailbox(request: Request, env: Env, explicit?: string | null): string | null {
|
||||
const url = new URL(request.url);
|
||||
const mailbox = explicit ?? url.searchParams.get("mailbox") ?? request.headers.get("x-mailbox") ?? env.DEFAULT_MAILBOX ?? "";
|
||||
return mailbox ? normalizeEmail(mailbox) : null;
|
||||
}
|
||||
|
||||
export function allowedDomains(env: Env): string[] {
|
||||
return String(env.DOMAINS ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isAllowedMailbox(mailbox: string, env: Env): boolean {
|
||||
const domains = allowedDomains(env);
|
||||
if (domains.length === 0) return true;
|
||||
const domain = mailbox.split("@")[1] ?? "";
|
||||
return domains.includes(domain.toLowerCase());
|
||||
}
|
||||
|
||||
export function parseJson<T>(request: Request): Promise<T> {
|
||||
return request.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function ensureArray<T>(value: T | T[] | undefined | null): T[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
export function normalizeSubject(subject: string): string {
|
||||
return (subject || "(no subject)").replace(REPLY_PREFIX_RE, "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function extractEmails(value: string | undefined | null): string[] {
|
||||
if (!value) return [];
|
||||
const found = value.match(EMAIL_RE) ?? [];
|
||||
return [...new Set(found.map((item) => normalizeEmail(item)))];
|
||||
}
|
||||
|
||||
export function buildParticipants(sender: string, recipient: string, cc?: string | null, bcc?: string | null): string {
|
||||
const values = new Set<string>([
|
||||
...extractEmails(sender),
|
||||
...extractEmails(recipient),
|
||||
...extractEmails(cc ?? undefined),
|
||||
...extractEmails(bcc ?? undefined),
|
||||
]);
|
||||
return [...values].sort().join(",");
|
||||
}
|
||||
|
||||
export function participantsOverlap(left: string, right: string): boolean {
|
||||
const a = new Set(left.split(",").map((item) => item.trim()).filter(Boolean));
|
||||
const b = new Set(right.split(",").map((item) => item.trim()).filter(Boolean));
|
||||
for (const value of a) {
|
||||
if (b.has(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function normalizeMessageId(value: string | undefined | null): string {
|
||||
if (!value) return "";
|
||||
return value.trim().replace(/^<+|>+$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function parseReferences(value: string | undefined | null): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(/\s+/)
|
||||
.map((item) => normalizeMessageId(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildSnippet(textValue: string, htmlValue: string): string {
|
||||
const source = textValue?.trim() || stripHtml(htmlValue);
|
||||
return source.replace(/\s+/g, " ").trim().slice(0, 220);
|
||||
}
|
||||
|
||||
export function stripHtml(html: string | undefined | null): string {
|
||||
if (!html) return "";
|
||||
return html.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function toBase64(input: ArrayBuffer | Uint8Array | string): string {
|
||||
if (typeof input === "string") {
|
||||
return btoa(input);
|
||||
}
|
||||
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
let binary = "";
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function fromBase64(input: string): Uint8Array {
|
||||
const binary = atob(input);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function safeFilename(input: string): string {
|
||||
return input.replace(/[\\/:*?"<>|]+/g, "-").trim() || "attachment.bin";
|
||||
}
|
||||
|
||||
export function quoteHtml(textValue: string): string {
|
||||
return textValue
|
||||
.split("\n")
|
||||
.map((line) => `<blockquote>${escapeHtml(line || " ")}</blockquote>`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function makeMessageId(mailbox: string): string {
|
||||
const domain = mailbox.split("@")[1] ?? "localhost";
|
||||
return `${crypto.randomUUID()}@${domain}`;
|
||||
}
|
||||
|
||||
export function formatAddress(mailbox: string, name?: string): string | { email: string; name: string } {
|
||||
if (!name?.trim()) return mailbox;
|
||||
return { email: mailbox, name: name.trim() };
|
||||
}
|
||||
|
||||
export function attachmentSize(base64: string): number {
|
||||
const trimmed = base64.replace(/=+$/, "");
|
||||
return Math.floor((trimmed.length * 3) / 4);
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentInput(input: StoredAttachmentInput): StoredAttachmentInput {
|
||||
return {
|
||||
filename: safeFilename(input.filename),
|
||||
type: input.type || "application/octet-stream",
|
||||
disposition: input.disposition ?? "attachment",
|
||||
contentId: input.contentId ?? undefined,
|
||||
contentBase64: input.contentBase64,
|
||||
size: input.size ?? attachmentSize(input.contentBase64),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user