import { htmlToText as coreHtmlToText } from "../../core/html-to-text.ts";
import type { DeliveryResult, MailAdapter, OwnerRef, ParsedMail } from "../../core/types.ts";
import { postMessage, uploadFile, SlackApiError, type SlackFile } from "./slack-rest.ts";
// Conservative caps, not Slack's actual platform maximums -- chosen the
// same way Discord's and Telegram's adapters picked theirs: a point past
// which a message stops being a quick DM to skim and should be an
// attachment instead.
const INLINE_BODY_CAP = 3500;
const SLACK_FILE_CAP = 50 * 1024 * 1024;
const MAX_HTML_LENGTH = 256 * 1024;
const SPARSE_TEXT_THRESHOLD = 200;
const SUBSTANTIAL_HTML_THRESHOLD = 1500;
function htmlToText(html: string): string {
return coreHtmlToText(html, {
// mrkdwn has its own small set of special characters (*_~`), but
// escaping them would mangle legitimate-looking formatting in
// forwarded mail worse than just leaving it plain -- same tradeoff
// Telegram's plain-text send makes, just for a different syntax.
escape: (text) => text,
formatLink: (url) => `(${url})`,
});
}
function truncateAtLineBoundary(text: string, maxLength: number): string {
const lines = text.split("\n");
let result = "";
for (const line of lines) {
const candidate = result ? `${result}\n${line}` : line;
if (candidate.length > maxLength) {
break;
}
result = candidate;
}
return result || text.slice(0, maxLength);
}
export function createSlackAdapter(botToken: string): MailAdapter {
return {
name: "slack",
async deliver(owner: OwnerRef, mail: ParsedMail): Promise {
try {
const header = `From: ${mail.from}\nTo: ${mail.to}\nSubject: ${mail.subject}\n`;
const files: SlackFile[] = [];
const notes: string[] = [];
const html = mail.html && mail.html.length > MAX_HTML_LENGTH ? mail.html.slice(0, MAX_HTML_LENGTH) : mail.html;
const readableText = html ? htmlToText(html) : mail.text;
let bodyText = readableText;
if (readableText.length > INLINE_BODY_CAP) {
bodyText = `${truncateAtLineBoundary(readableText, INLINE_BODY_CAP - 1)}…`;
notes.push("(full message attached)");
files.push({
filename: "message.txt",
contentType: "text/plain; charset=utf-8",
content: new TextEncoder().encode(readableText).buffer as ArrayBuffer,
});
} else if (readableText.length === 0) {
bodyText = "(no readable content)";
}
if (html && readableText.trim().length < SPARSE_TEXT_THRESHOLD && html.length > SUBSTANTIAL_HTML_THRESHOLD) {
notes.push("(mostly images, original HTML attached; open it in a browser to view)");
files.push({
filename: "message.html",
contentType: "text/html; charset=utf-8",
content: new TextEncoder().encode(html).buffer as ArrayBuffer,
});
}
// Slack files are uploaded one at a time via the external-upload
// flow (no bundled multi-attachment payload the way Discord sends
// files alongside a message), so budget and skip per file against
// its own cap rather than a combined one.
let skipped = 0;
const toSend: SlackFile[] = [];
for (const a of mail.attachments) {
if (a.size <= SLACK_FILE_CAP) {
toSend.push({ filename: a.filename, contentType: a.contentType, content: a.content });
} else {
skipped++;
}
}
if (skipped > 0) {
notes.push(`(${skipped} attachment${skipped === 1 ? "" : "s"} too large, discarded)`);
}
let content = `${header}\n${bodyText}`;
if (notes.length > 0) {
content += `\n\n${notes.join("\n")}`;
}
await postMessage(botToken, owner.id, content);
for (const file of [...files, ...toSend]) {
await uploadFile(botToken, owner.id, file);
}
return { success: true };
} catch (err) {
if (err instanceof SlackApiError) {
return { success: false, error: err.message };
}
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
async notify(owner: OwnerRef, message: string): Promise {
try {
await postMessage(botToken, owner.id, message);
return { success: true };
} catch (err) {
if (err instanceof SlackApiError) {
return { success: false, error: err.message };
}
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
};
}