Realne sluzby, OpenAI, odesilani e-mailu a helpdesk
Katalog srovnany s tim, co opravdu bezi na services.csbot.cz/apps: trinact sluzeb dostalo pristupove udaje a levne cteci overeni, opravena appId, ktera nikam nevedla (ppl, microsoft365, transcription), a GA4, Search Console, Google Ads i Sklik ted stoji na aplikaci analytics, kazda s vlastnimi udaji. Nove sluzby SAP Business One, Google Workspace a Meta Ads. K tomu 23 skriptu, ktere s nimi opravdu neco delaji. OpenAI jako prvni sluzba, ktera nebezi u nas: Service.baseUrl s absolutni adresou, prepis pres <SLUZBA>_BASE_URL nebo adresu u konektoru, predpona hlavicky u pole udaju (uzivatel vlepi holy klic, Bearer dopise runtime). Dotaz na model, nahrani souboru, otazka nad souborem, prepis zvuku. Skript umi odeslat soubor pres ctx.http.postForm (multipart, obsah Base64). Sluzba E-mail pres SMTP. Neni to skript, ale vnitrni krok - SMTP neni HTTP. Konektor nese schranku firmy, krok ma HTML telo, ve kterem se dosazene hodnoty escapuji (znacky autora sablony jsou zamer, ostre zavorky od zakaznika ne). Overeni konektoru se prihlasi na server a nic neodesle. Helpdesk: Ticket.helpdeskSourceId drzi firmu, ktera pozadavek poslala, vlastnikem zustava ta, ktera ho resi - jinak by ho resitel nemel ve sve fronte. Komu pozadavek pripadne, urcuje Tenant.helpdeskProviderId. Zadavatel vidi jen svoje pozadavky a smi k nim pripsat komentar. Opravy v portalu: - hlasky o ulozisti a odchozi IP vidi jen spravce platformy - typ ticketu se v automatizaci vybira ze seznamu firmy, nebo dosadi z dat - stav ticketu je otevreny naseptavac, ne ciselnik - ticket jde zalozit rucne, zakaznik u nej neni povinny - kanal se prejmenoval a parametry u webhooku jsou oznacene jako nepovinne - srovnane markdown tabulky v cele dokumentaci Co z teto davky jeste neni: prepinac firmy je porad jen stav uvnitr stranky Prehled, takze se prepnuti neprojevi v Lidech ani jinde. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b25a149574
commit
a771834e57
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Odesilani e-mailu pres SMTP.
|
||||
*
|
||||
* Proc to neni skript: skript umi jen `ctx.http`. SMTP neni HTTP, takze by
|
||||
* musel dostat sit, a tim by padlo pravidlo, ze skript nema jak zavolat ven
|
||||
* jinudy nez pres nas klient. E-mail je proto **vnitrni krok** stejne jako
|
||||
* zalozeni ticketu, jen misto do naseho uloziste sahá na posmovni server.
|
||||
*
|
||||
* Pristupove udaje ale zustavaji tam, kde maji - v konektoru firmy. Kazda
|
||||
* firma odesila ze sve schranky a nikdo z nich nesmi videt hodnoty ostatnich.
|
||||
*
|
||||
* Chyby se **nevyhazuji**. Vraci se vysledek s `ok`, hlaskou a priznakem,
|
||||
* jestli ma smysl zkusit znovu: plna schranka za hodinu neni, spatne heslo
|
||||
* za hodinu porad spatne bude.
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config.js';
|
||||
import type { ResolvedTarget } from '../scripts/connections.js';
|
||||
import { isPrivateHost } from '../scripts/http.js';
|
||||
import { createRedactor, truncate } from '../scripts/util.js';
|
||||
|
||||
export interface SmtpSettings {
|
||||
host: string;
|
||||
port: number;
|
||||
/** true = sifrovane od navazani spojeni (port 465). */
|
||||
secure: boolean;
|
||||
/** true = vyzadovat STARTTLS, tedy nespokojit se s nesifrovanym spojenim. */
|
||||
requireTls: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
from: string;
|
||||
fromName: string | null;
|
||||
replyTo: string | null;
|
||||
}
|
||||
|
||||
export interface MailMessage {
|
||||
to: string;
|
||||
cc?: string | null;
|
||||
bcc?: string | null;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string | null;
|
||||
replyTo?: string | null;
|
||||
}
|
||||
|
||||
export interface MailResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
/** Cela odpoved serveru, uz zredigovana. Tam je napsane, co mu vadilo. */
|
||||
detail: string | null;
|
||||
/** Ciselny kod SMTP, kdyz se k odpovedi serveru vubec doslo. */
|
||||
status: number | null;
|
||||
/** true = docasna prekazka, opakovani ma smysl. */
|
||||
retryable: boolean;
|
||||
/** Vyplnene jen u uspesneho odeslani. */
|
||||
messageId?: string;
|
||||
accepted?: number;
|
||||
rejected?: number;
|
||||
}
|
||||
|
||||
/** Kam se to posilalo. Do hlasky i na kartu konektoru, bez udaju. */
|
||||
export function smtpTargetUrl(settings: SmtpSettings): string {
|
||||
return `smtp://${settings.host}:${settings.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nastaveni z konektoru.
|
||||
*
|
||||
* Vraci `null` a duvod, kdyz udaje nedavaji smysl. Padat na tom nemuzeme:
|
||||
* spatne vyplneny konektor je bezny stav, ne chyba aplikace.
|
||||
*/
|
||||
export function smtpSettings(target: ResolvedTarget): { settings: SmtpSettings } | { error: string } {
|
||||
const value = (key: string): string => (target.serviceConfig[key] ?? '').trim();
|
||||
|
||||
const host = value('host');
|
||||
const user = value('user');
|
||||
const password = value('password');
|
||||
const from = value('from') || user;
|
||||
|
||||
if (host === '') return { error: 'Konektor nemá vyplněný SMTP server.' };
|
||||
// Stejne pravidlo jako u HTTP: adresu vyplnuje firma, takze tohle je jedina
|
||||
// zabrana proti navazani spojeni dovnitr nasi site.
|
||||
if (!config.allowPrivateTargets && isPrivateHost(host)) {
|
||||
return {
|
||||
error:
|
||||
`Server ${host} míří do vnitřní sítě. ` +
|
||||
'Pro místní vývoj nastavte ALLOW_PRIVATE_TARGETS=true.',
|
||||
};
|
||||
}
|
||||
if (user === '') return { error: 'Konektor nemá vyplněného uživatele.' };
|
||||
if (password === '') return { error: 'Konektor nemá vyplněné heslo.' };
|
||||
if (from === '') return { error: 'Konektor nemá vyplněnou adresu odesílatele.' };
|
||||
|
||||
const port = Number(value('port'));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return { error: `Port "${value('port')}" není platné číslo portu.` };
|
||||
}
|
||||
|
||||
/*
|
||||
* Prazdne sifrovani se ridi portem, protoze to je zvyklost, kterou zna
|
||||
* kazdy: 465 je sifrovane od zacatku, 587 zacina necisté a povysi se
|
||||
* STARTTLS. Vyslovna hodnota to prebije - jsou servery, ktere to maji jinak.
|
||||
*/
|
||||
const security = value('security').toLowerCase();
|
||||
let secure = port === 465;
|
||||
let requireTls = !secure;
|
||||
|
||||
if (security !== '') {
|
||||
if (security === 'ssl' || security === 'tls') {
|
||||
secure = true;
|
||||
requireTls = false;
|
||||
} else if (security === 'starttls') {
|
||||
secure = false;
|
||||
requireTls = true;
|
||||
} else if (security === 'zadne' || security === 'žádné' || security === 'none') {
|
||||
secure = false;
|
||||
requireTls = false;
|
||||
} else {
|
||||
return { error: `Šifrování "${security}" neznáme. Použijte ssl, starttls nebo zadne.` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings: {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
requireTls,
|
||||
user,
|
||||
password,
|
||||
from,
|
||||
fromName: value('fromName') || null,
|
||||
replyTo: value('replyTo') || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function transportFor(settings: SmtpSettings) {
|
||||
return nodemailer.createTransport({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
secure: settings.secure,
|
||||
requireTLS: settings.requireTls,
|
||||
auth: { user: settings.user, pass: settings.password },
|
||||
// Bez stropu by krok visel na nedostupnem serveru, dokud ho nekdo nezabije.
|
||||
connectionTimeout: 15_000,
|
||||
greetingTimeout: 15_000,
|
||||
socketTimeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Kody od nodemaileru, u kterych ma smysl zkusit znovu. */
|
||||
const retryableCodes = new Set(['ECONNECTION', 'ETIMEDOUT', 'ESOCKET', 'EDNS', 'ECONNRESET']);
|
||||
|
||||
/**
|
||||
* Prevede vyjimku od nodemaileru na vysledek.
|
||||
*
|
||||
* Duvod, proc se to nevraci jen jako `err.message`: hlaska nodemaileru je
|
||||
* casto jen "Invalid login", kdezto server k tomu napsal celou vetu o tom,
|
||||
* ze ucet chce heslo pro aplikaci. Prave ta veta cloveku pomuze.
|
||||
*/
|
||||
function failure(err: unknown, settings: SmtpSettings, what: string): MailResult {
|
||||
// Redaguje se heslo, ne uzivatel. Uzivatel je adresa schranky a prave ta
|
||||
// v hlasce pomaha - skrtnout ji by z vety udelalo hadanku.
|
||||
const redact = createRedactor([settings.password]);
|
||||
const error = err as { code?: string; responseCode?: number; response?: string; message?: string };
|
||||
|
||||
const code = error?.code ?? '';
|
||||
const status = typeof error?.responseCode === 'number' ? error.responseCode : null;
|
||||
const said = error?.response ? ` Server odpověděl: ${redact(error.response)}` : '';
|
||||
const where = `${what} na ${smtpTargetUrl(settings)}`;
|
||||
|
||||
// 4xx je docasne odmitnuti (fronta, limit), 5xx koncove.
|
||||
const softStatus = status !== null && status >= 400 && status < 500;
|
||||
const retryable = retryableCodes.has(code) || softStatus;
|
||||
|
||||
let message: string;
|
||||
if (code === 'EAUTH' || status === 535) {
|
||||
message =
|
||||
`Přihlášení odmítnuto: ${where}. Server údaje dostal a neuznal je, jde tedy ` +
|
||||
'o uživatele nebo heslo. U schránek s dvoufázovým ověřením musí být heslo ' +
|
||||
`pro aplikaci, ne heslo k účtu.${said}`;
|
||||
} else if (code === 'EENVELOPE') {
|
||||
message =
|
||||
`Adresy neprošly: ${where}. Server nepřijal odesílatele nebo některého ` +
|
||||
`příjemce. Odesílatel musí být adresa, kterou má účet povolenou.${said}`;
|
||||
} else if (retryableCodes.has(code)) {
|
||||
message =
|
||||
`Nepodařilo se spojit se serverem: ${where} (${code}). Zkontrolujte adresu, ` +
|
||||
`port a šifrování.${said}`;
|
||||
} else {
|
||||
message = `${what} selhalo na ${smtpTargetUrl(settings)}.${said || ` ${error?.message ?? ''}`}`;
|
||||
}
|
||||
|
||||
const detail = redact(
|
||||
truncate(
|
||||
[error?.message, error?.response, code && `code=${code}`].filter(Boolean).join('\n'),
|
||||
config.errorDetailBytes,
|
||||
),
|
||||
);
|
||||
|
||||
return { ok: false, message: message.trim(), detail: detail || null, status, retryable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze se na server da prihlasit.
|
||||
*
|
||||
* `verify` nic neposila, jen naváže spojeni a prihlasi se. Presne to, co
|
||||
* overeni konektoru potrebuje: **cteci** akce, ktera bez platnych udaju
|
||||
* neprojde a nikomu nic nedorucí.
|
||||
*/
|
||||
export async function verifySmtp(target: ResolvedTarget): Promise<MailResult> {
|
||||
const resolved = smtpSettings(target);
|
||||
if ('error' in resolved) {
|
||||
return { ok: false, message: resolved.error, detail: null, status: null, retryable: false };
|
||||
}
|
||||
|
||||
const transport = transportFor(resolved.settings);
|
||||
try {
|
||||
await transport.verify();
|
||||
return {
|
||||
ok: true,
|
||||
message: `Přihlášení na ${smtpTargetUrl(resolved.settings)} funguje, údaje jsou platné.`,
|
||||
detail: null,
|
||||
status: null,
|
||||
retryable: false,
|
||||
};
|
||||
} catch (err) {
|
||||
return failure(err, resolved.settings, 'Přihlášení');
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Textova verze z HTML.
|
||||
*
|
||||
* Neni to plnohodnotny prevod a byt nemusi: je to nahradnik pro klienty, ktere
|
||||
* HTML nezobrazi. Bez nej by takovy prijemce dostal prazdnou zpravu a filtry
|
||||
* nespamu berou chybejici textovou cast jako priznak spamu.
|
||||
*/
|
||||
export function textFromHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|tr|h[1-6]|li)>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '- ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Odesle jednu zpravu. Nikdy nevyhodi vyjimku. */
|
||||
export async function sendMail(target: ResolvedTarget, message: MailMessage): Promise<MailResult> {
|
||||
const resolved = smtpSettings(target);
|
||||
if ('error' in resolved) {
|
||||
return { ok: false, message: resolved.error, detail: null, status: null, retryable: false };
|
||||
}
|
||||
|
||||
const settings = resolved.settings;
|
||||
const transport = transportFor(settings);
|
||||
|
||||
try {
|
||||
const info = await transport.sendMail({
|
||||
from: settings.fromName
|
||||
? { name: settings.fromName, address: settings.from }
|
||||
: settings.from,
|
||||
to: message.to,
|
||||
...(message.cc ? { cc: message.cc } : {}),
|
||||
...(message.bcc ? { bcc: message.bcc } : {}),
|
||||
// Krok smi adresu pro odpovedi prebit: odpoved casto ma zamirit do
|
||||
// ticketu, ne do schranky, ze ktere se odeslalo.
|
||||
...(message.replyTo ?? settings.replyTo
|
||||
? { replyTo: message.replyTo ?? settings.replyTo ?? undefined }
|
||||
: {}),
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text && message.text.trim() !== '' ? message.text : textFromHtml(message.html),
|
||||
});
|
||||
|
||||
const accepted = Array.isArray(info.accepted) ? info.accepted.length : 0;
|
||||
const rejected = Array.isArray(info.rejected) ? info.rejected.length : 0;
|
||||
|
||||
return {
|
||||
ok: rejected === 0,
|
||||
message:
|
||||
rejected === 0
|
||||
? `Odesláno ${accepted} příjemcům.`
|
||||
: `Odesláno ${accepted} příjemcům, ${rejected} server nepřijal.`,
|
||||
detail: typeof info.response === 'string' ? info.response : null,
|
||||
status: null,
|
||||
retryable: false,
|
||||
messageId: typeof info.messageId === 'string' ? info.messageId : '',
|
||||
accepted,
|
||||
rejected,
|
||||
};
|
||||
} catch (err) {
|
||||
return failure(err, settings, 'Odeslání');
|
||||
} finally {
|
||||
transport.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user