mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Migrate email pipeline off Lovable + branded auth emails
Replace Lovable-bound email transport and auth webhook so the platform sends all automated email through its own infrastructure. - process-email-queue: drop sendLovableEmail/LOVABLE_API_KEY; send via the Hostinger Email API (primary) with automatic SMTP fallback. Shared transports added in _shared/hostinger-mail.ts and _shared/smtp-send.ts. - auth-email-hook: verify Supabase's native Send Email hook signature (Standard Webhooks via SEND_EMAIL_HOOK_SECRET) instead of Lovable's libs; build the GoTrue verify URL; keep enqueue → process-email-queue. - Recreate the 6 auth email templates under _shared/email-templates/ that previously only existed in the deployed function. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/// <reference types="npm:@types/react@18.3.1" />
|
||||
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Text,
|
||||
} from 'npm:@react-email/components@0.0.22'
|
||||
|
||||
interface EmailChangeEmailProps {
|
||||
siteName: string
|
||||
email: string
|
||||
newEmail: string
|
||||
confirmationUrl: string
|
||||
}
|
||||
|
||||
export const EmailChangeEmail = ({
|
||||
siteName,
|
||||
email,
|
||||
newEmail,
|
||||
confirmationUrl,
|
||||
}: EmailChangeEmailProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Confirm your email change for {siteName}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Confirm your email change</Heading>
|
||||
<Text style={text}>
|
||||
You requested to change your email address for {siteName} from{' '}
|
||||
<Link href={`mailto:${email}`} style={link}>
|
||||
{email}
|
||||
</Link>{' '}
|
||||
to{' '}
|
||||
<Link href={`mailto:${newEmail}`} style={link}>
|
||||
{newEmail}
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Click the button below to confirm this change:
|
||||
</Text>
|
||||
<Button style={button} href={confirmationUrl}>
|
||||
Confirm Email Change
|
||||
</Button>
|
||||
<Text style={footer}>
|
||||
If you didn't request this change, please secure your account
|
||||
immediately.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export default EmailChangeEmail
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' }
|
||||
const container = { padding: '20px 25px' }
|
||||
const h1 = {
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold' as const,
|
||||
color: '#000000',
|
||||
margin: '0 0 20px',
|
||||
}
|
||||
const text = {
|
||||
fontSize: '14px',
|
||||
color: '#55575d',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 25px',
|
||||
}
|
||||
const link = { color: 'inherit', textDecoration: 'underline' }
|
||||
const button = {
|
||||
backgroundColor: '#000000',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' }
|
||||
@@ -0,0 +1,90 @@
|
||||
/// <reference types="npm:@types/react@18.3.1" />
|
||||
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Text,
|
||||
} from 'npm:@react-email/components@0.0.22'
|
||||
|
||||
interface InviteEmailProps {
|
||||
siteName: string
|
||||
siteUrl: string
|
||||
confirmationUrl: string
|
||||
}
|
||||
|
||||
export const InviteEmail = ({
|
||||
siteName,
|
||||
siteUrl,
|
||||
confirmationUrl,
|
||||
}: InviteEmailProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>You've been invited to join {siteName}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>You've been invited</Heading>
|
||||
<Text style={text}>
|
||||
You've been invited to join{' '}
|
||||
<Link href={siteUrl} style={link}>
|
||||
<strong>{siteName}</strong>
|
||||
</Link>
|
||||
. Click the button below to accept the invitation and create your
|
||||
account.
|
||||
</Text>
|
||||
<Button style={button} href={confirmationUrl}>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
<Text style={fallbackText}>
|
||||
If the button does not open, copy and paste this secure link into your browser:
|
||||
</Text>
|
||||
<Text style={fallbackLinkWrapper}>
|
||||
<Link href={confirmationUrl} style={fallbackLink}>
|
||||
{confirmationUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text style={footer}>
|
||||
If you weren't expecting this invitation, you can safely ignore this
|
||||
email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export default InviteEmail
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' }
|
||||
const container = { padding: '20px 25px' }
|
||||
const h1 = {
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold' as const,
|
||||
color: '#000000',
|
||||
margin: '0 0 20px',
|
||||
}
|
||||
const text = {
|
||||
fontSize: '14px',
|
||||
color: '#55575d',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 25px',
|
||||
}
|
||||
const link = { color: 'inherit', textDecoration: 'underline' }
|
||||
const button = {
|
||||
backgroundColor: '#000000',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
const fallbackText = { fontSize: '13px', color: '#55575d', lineHeight: '1.5', margin: '24px 0 8px' }
|
||||
const fallbackLinkWrapper = { margin: '0 0 24px', wordBreak: 'break-all' as const }
|
||||
const fallbackLink = { color: '#000000', fontSize: '13px', lineHeight: '1.5', textDecoration: 'underline' }
|
||||
const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' }
|
||||
@@ -0,0 +1,70 @@
|
||||
/// <reference types="npm:@types/react@18.3.1" />
|
||||
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from 'npm:@react-email/components@0.0.22'
|
||||
|
||||
interface MagicLinkEmailProps {
|
||||
siteName: string
|
||||
confirmationUrl: string
|
||||
}
|
||||
|
||||
export const MagicLinkEmail = ({
|
||||
siteName,
|
||||
confirmationUrl,
|
||||
}: MagicLinkEmailProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Your login link for {siteName}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Your login link</Heading>
|
||||
<Text style={text}>
|
||||
Click the button below to log in to {siteName}. This link will expire
|
||||
shortly.
|
||||
</Text>
|
||||
<Button style={button} href={confirmationUrl}>
|
||||
Log In
|
||||
</Button>
|
||||
<Text style={footer}>
|
||||
If you didn't request this link, you can safely ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export default MagicLinkEmail
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' }
|
||||
const container = { padding: '20px 25px' }
|
||||
const h1 = {
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold' as const,
|
||||
color: '#000000',
|
||||
margin: '0 0 20px',
|
||||
}
|
||||
const text = {
|
||||
fontSize: '14px',
|
||||
color: '#55575d',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 25px',
|
||||
}
|
||||
const button = {
|
||||
backgroundColor: '#000000',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' }
|
||||
@@ -0,0 +1,60 @@
|
||||
/// <reference types="npm:@types/react@18.3.1" />
|
||||
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from 'npm:@react-email/components@0.0.22'
|
||||
|
||||
interface ReauthenticationEmailProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
export const ReauthenticationEmail = ({ token }: ReauthenticationEmailProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Your verification code</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Confirm reauthentication</Heading>
|
||||
<Text style={text}>Use the code below to confirm your identity:</Text>
|
||||
<Text style={codeStyle}>{token}</Text>
|
||||
<Text style={footer}>
|
||||
This code will expire shortly. If you didn't request this, you can
|
||||
safely ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export default ReauthenticationEmail
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' }
|
||||
const container = { padding: '20px 25px' }
|
||||
const h1 = {
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold' as const,
|
||||
color: '#000000',
|
||||
margin: '0 0 20px',
|
||||
}
|
||||
const text = {
|
||||
fontSize: '14px',
|
||||
color: '#55575d',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 25px',
|
||||
}
|
||||
const codeStyle = {
|
||||
fontFamily: 'Courier, monospace',
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold' as const,
|
||||
color: '#000000',
|
||||
margin: '0 0 30px',
|
||||
}
|
||||
const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' }
|
||||
@@ -0,0 +1,71 @@
|
||||
/// <reference types="npm:@types/react@18.3.1" />
|
||||
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Text,
|
||||
} from 'npm:@react-email/components@0.0.22'
|
||||
|
||||
interface RecoveryEmailProps {
|
||||
siteName: string
|
||||
confirmationUrl: string
|
||||
}
|
||||
|
||||
export const RecoveryEmail = ({
|
||||
siteName,
|
||||
confirmationUrl,
|
||||
}: RecoveryEmailProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Reset your password for {siteName}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Reset your password</Heading>
|
||||
<Text style={text}>
|
||||
We received a request to reset your password for {siteName}. Click
|
||||
the button below to choose a new password.
|
||||
</Text>
|
||||
<Button style={button} href={confirmationUrl}>
|
||||
Reset Password
|
||||
</Button>
|
||||
<Text style={footer}>
|
||||
If you didn't request a password reset, you can safely ignore this
|
||||
email. Your password will not be changed.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export default RecoveryEmail
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' }
|
||||
const container = { padding: '20px 25px' }
|
||||
const h1 = {
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold' as const,
|
||||
color: '#000000',
|
||||
margin: '0 0 20px',
|
||||
}
|
||||
const text = {
|
||||
fontSize: '14px',
|
||||
color: '#55575d',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 25px',
|
||||
}
|
||||
const button = {
|
||||
backgroundColor: '#000000',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' }
|
||||
@@ -0,0 +1,86 @@
|
||||
/// <reference types="npm:@types/react@18.3.1" />
|
||||
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Text,
|
||||
} from 'npm:@react-email/components@0.0.22'
|
||||
|
||||
interface SignupEmailProps {
|
||||
siteName: string
|
||||
siteUrl: string
|
||||
recipient: string
|
||||
confirmationUrl: string
|
||||
}
|
||||
|
||||
export const SignupEmail = ({
|
||||
siteName,
|
||||
siteUrl,
|
||||
recipient,
|
||||
confirmationUrl,
|
||||
}: SignupEmailProps) => (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head />
|
||||
<Preview>Confirm your email for {siteName}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Heading style={h1}>Confirm your email</Heading>
|
||||
<Text style={text}>
|
||||
Thanks for signing up for{' '}
|
||||
<Link href={siteUrl} style={link}>
|
||||
<strong>{siteName}</strong>
|
||||
</Link>
|
||||
!
|
||||
</Text>
|
||||
<Text style={text}>
|
||||
Please confirm your email address (
|
||||
<Link href={`mailto:${recipient}`} style={link}>
|
||||
{recipient}
|
||||
</Link>
|
||||
) by clicking the button below:
|
||||
</Text>
|
||||
<Button style={button} href={confirmationUrl}>
|
||||
Verify Email
|
||||
</Button>
|
||||
<Text style={footer}>
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
|
||||
export default SignupEmail
|
||||
|
||||
const main = { backgroundColor: '#ffffff', fontFamily: 'Arial, sans-serif' }
|
||||
const container = { padding: '20px 25px' }
|
||||
const h1 = {
|
||||
fontSize: '22px',
|
||||
fontWeight: 'bold' as const,
|
||||
color: '#000000',
|
||||
margin: '0 0 20px',
|
||||
}
|
||||
const text = {
|
||||
fontSize: '14px',
|
||||
color: '#55575d',
|
||||
lineHeight: '1.5',
|
||||
margin: '0 0 25px',
|
||||
}
|
||||
const link = { color: 'inherit', textDecoration: 'underline' }
|
||||
const button = {
|
||||
backgroundColor: '#000000',
|
||||
color: '#ffffff',
|
||||
fontSize: '14px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 20px',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
const footer = { fontSize: '12px', color: '#999999', margin: '30px 0 0' }
|
||||
@@ -0,0 +1,71 @@
|
||||
// Hostinger Email API transport (https://api.mail.hostinger.com).
|
||||
//
|
||||
// Send endpoint: POST /api/v1/mailboxes/{mailboxResourceId}/send
|
||||
// Auth: Authorization: Bearer <token> (token + mailbox are scoped to one order)
|
||||
// The From identity is implicit — it is the mailbox the token is authorized for,
|
||||
// so the body carries no `from` field.
|
||||
//
|
||||
// `sendViaHostingerMail` throws on any non-2xx so the caller can fall back to
|
||||
// SMTP and/or let the queue retry. The thrown Error carries `.status` so HTTP
|
||||
// 429 can be mapped to the dispatcher's existing rate-limit backoff.
|
||||
|
||||
export type HostingerMailConfig = {
|
||||
token: string;
|
||||
mailboxResourceId: string;
|
||||
};
|
||||
|
||||
export type HostingerMessage = {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const BASE_URL = 'https://api.mail.hostinger.com';
|
||||
|
||||
export class HostingerMailError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'HostingerMailError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendViaHostingerMail(
|
||||
cfg: HostingerMailConfig,
|
||||
msg: HostingerMessage,
|
||||
): Promise<void> {
|
||||
if (!cfg.token || !cfg.mailboxResourceId) {
|
||||
throw new Error('Hostinger mail config incomplete (token and mailboxResourceId required)');
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/api/v1/mailboxes/${encodeURIComponent(cfg.mailboxResourceId)}/send`;
|
||||
const body = {
|
||||
to: [msg.to],
|
||||
subject: msg.subject,
|
||||
html: msg.html,
|
||||
text: msg.text && msg.text.trim() ? msg.text : undefined,
|
||||
};
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${cfg.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let detail = '';
|
||||
try { detail = (await resp.text()).slice(0, 500); } catch { /* ignore */ }
|
||||
throw new HostingerMailError(
|
||||
`Hostinger mail API ${resp.status} ${resp.statusText}: ${detail}`,
|
||||
resp.status,
|
||||
);
|
||||
}
|
||||
// Drain the body so the connection can be reused.
|
||||
try { await resp.text(); } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Self-contained SMTP transport for server-to-server (queued) email.
|
||||
//
|
||||
// Extracted from the proven conversation in `send-smtp-email` so the automated
|
||||
// email dispatcher (`process-email-queue`) can send through the same SMTP path
|
||||
// the app already uses — no third-party email API required.
|
||||
//
|
||||
// `sendSmtpMessage` throws on any failure so the caller's existing retry / DLQ
|
||||
// logic can record and reschedule the message.
|
||||
|
||||
export type SmtpSender = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
use_tls?: boolean;
|
||||
use_ssl?: boolean;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
};
|
||||
|
||||
export type SmtpMessage = {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
/** When set, adds List-Unsubscribe + one-click headers (bulk deliverability). */
|
||||
unsubscribeUrl?: string;
|
||||
};
|
||||
|
||||
const SMTP_CONNECT_TIMEOUT_MS = 20000;
|
||||
const SMTP_COMMAND_TIMEOUT_MS = 30000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timeoutId: number | undefined;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
promise.then(resolve).catch(reject).finally(() => {
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeHeaderValue(value: string): string {
|
||||
return String(value ?? "").replace(/[\r\n]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function formatAddressHeader(email: string, displayName?: string): string {
|
||||
const safeEmail = sanitizeHeaderValue(email);
|
||||
const safeName = sanitizeHeaderValue(displayName || "");
|
||||
if (!safeName) return safeEmail;
|
||||
const escaped = safeName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const needsQuotes = /[",;<>@\[\]\(\):]/.test(escaped);
|
||||
return needsQuotes ? `"${escaped}" <${safeEmail}>` : `${escaped} <${safeEmail}>`;
|
||||
}
|
||||
|
||||
function toBase64Utf8(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function chunkBase64(value: string, lineLength = 76): string {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < value.length; i += lineLength) lines.push(value.slice(i, i + lineLength));
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
|
||||
function dotStuff(content: string): string {
|
||||
return content.replace(/\r?\n/g, "\r\n").replace(/(^|\r\n)\./g, "$1..");
|
||||
}
|
||||
|
||||
function htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(p|div|h1|h2|h3|h4|h5|h6|li|tr|section)\s*>/gi, "\n")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">")
|
||||
.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
export async function sendSmtpMessage(sender: SmtpSender, msg: SmtpMessage): Promise<string> {
|
||||
const { host, password, username } = sender;
|
||||
const port = Number(sender.port || 587);
|
||||
const use_ssl = sender.use_ssl ?? port === 465;
|
||||
const use_tls = sender.use_tls ?? port === 587;
|
||||
|
||||
if (!host || !username || !password || !sender.fromEmail) {
|
||||
throw new Error("Incomplete SMTP sender configuration (host, username, password, fromEmail required)");
|
||||
}
|
||||
|
||||
const to = sanitizeHeaderValue(msg.to);
|
||||
if (!to) throw new Error("Missing recipient");
|
||||
|
||||
const fromHeader = formatAddressHeader(sender.fromEmail, sender.fromName);
|
||||
const subject = sanitizeHeaderValue(msg.subject);
|
||||
const html = String(msg.html ?? "").trim() || `<!DOCTYPE html><html><body></body></html>`;
|
||||
const text = msg.text && msg.text.trim() ? msg.text : (htmlToPlainText(html) || subject);
|
||||
const domain = sender.fromEmail.includes("@") ? sender.fromEmail.split("@")[1] : host;
|
||||
const messageId = `<${crypto.randomUUID()}@${domain}>`;
|
||||
const boundary = `----=_Part_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
|
||||
const headers: string[] = [
|
||||
`From: ${fromHeader}`,
|
||||
`To: ${to}`,
|
||||
`Reply-To: ${sanitizeHeaderValue(sender.fromEmail)}`,
|
||||
`Subject: ${subject}`,
|
||||
`Date: ${new Date().toUTCString()}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
];
|
||||
if (msg.unsubscribeUrl) {
|
||||
headers.push(`List-Unsubscribe: <${sanitizeHeaderValue(msg.unsubscribeUrl)}>`);
|
||||
headers.push(`List-Unsubscribe-Post: List-Unsubscribe=One-Click`);
|
||||
}
|
||||
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||
|
||||
let mime = headers.join("\r\n") + "\r\n\r\n";
|
||||
mime += `--${boundary}\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n`;
|
||||
mime += chunkBase64(toBase64Utf8(text)) + "\r\n\r\n";
|
||||
mime += `--${boundary}\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n`;
|
||||
mime += chunkBase64(toBase64Utf8(html)) + "\r\n\r\n";
|
||||
mime += `--${boundary}--\r\n`;
|
||||
|
||||
const conn: Deno.Conn = use_ssl
|
||||
? await withTimeout(Deno.connectTls({ hostname: host, port }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting (SSL) to ${host}:${port}`)
|
||||
: await withTimeout(Deno.connect({ hostname: host, port }), SMTP_CONNECT_TIMEOUT_MS, `Timed out connecting to ${host}:${port}`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const read = async (c: Deno.Conn): Promise<string> => {
|
||||
const buf = new Uint8Array(4096);
|
||||
const n = await withTimeout(c.read(buf), SMTP_COMMAND_TIMEOUT_MS, `Timed out awaiting SMTP response from ${host}`);
|
||||
if (n === null) throw new Error("SMTP server closed the connection unexpectedly");
|
||||
return decoder.decode(buf.subarray(0, n));
|
||||
};
|
||||
const cmd = async (c: Deno.Conn, command: string): Promise<string> => {
|
||||
await withTimeout(c.write(encoder.encode(command + "\r\n")), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending SMTP command to ${host}`);
|
||||
return await read(c);
|
||||
};
|
||||
|
||||
try {
|
||||
await read(conn);
|
||||
let active: Deno.Conn = conn;
|
||||
const ehlo = await cmd(active, "EHLO localhost");
|
||||
|
||||
if (!use_ssl && use_tls && ehlo.includes("STARTTLS")) {
|
||||
const st = await cmd(active, "STARTTLS");
|
||||
if (!st.startsWith("220")) throw new Error(`STARTTLS not available: ${st.trim()}`);
|
||||
active = await withTimeout(Deno.startTls(active as Deno.TcpConn, { hostname: host }), SMTP_COMMAND_TIMEOUT_MS, `Timed out on STARTTLS upgrade for ${host}`);
|
||||
await cmd(active, "EHLO localhost");
|
||||
}
|
||||
|
||||
await cmd(active, "AUTH LOGIN");
|
||||
await cmd(active, btoa(username));
|
||||
const authResp = await cmd(active, btoa(password));
|
||||
if (!authResp.startsWith("235")) throw new Error(`SMTP authentication failed for ${username}: ${authResp.trim()}`);
|
||||
|
||||
const mailFrom = await cmd(active, `MAIL FROM:<${sanitizeHeaderValue(sender.fromEmail)}>`);
|
||||
if (!mailFrom.startsWith("250")) throw new Error(`MAIL FROM rejected: ${mailFrom.trim()}`);
|
||||
|
||||
const rcpt = await cmd(active, `RCPT TO:<${to}>`);
|
||||
if (!rcpt.startsWith("250") && !rcpt.startsWith("251")) throw new Error(`RCPT TO rejected for ${to}: ${rcpt.trim()}`);
|
||||
|
||||
const dataResp = await cmd(active, "DATA");
|
||||
if (!dataResp.startsWith("354")) throw new Error(`DATA rejected: ${dataResp.trim()}`);
|
||||
|
||||
const payload = dotStuff(mime) + "\r\n.\r\n";
|
||||
const CHUNK = 4096;
|
||||
for (let off = 0; off < payload.length; off += CHUNK) {
|
||||
await withTimeout(active.write(encoder.encode(payload.slice(off, off + CHUNK))), SMTP_COMMAND_TIMEOUT_MS, `Timed out sending message body to ${host}`);
|
||||
}
|
||||
const finalResp = await read(active);
|
||||
try { await cmd(active, "QUIT"); } catch (_) { /* ignore */ }
|
||||
if (!finalResp.startsWith("250")) throw new Error(`Message rejected by ${host}: ${finalResp.trim()}`);
|
||||
return finalResp.trim();
|
||||
} finally {
|
||||
try { conn.close(); } catch (_) { /* already closed */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user