mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40: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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as React from 'npm:react@18.3.1'
|
import * as React from 'npm:react@18.3.1'
|
||||||
import { renderAsync } from 'npm:@react-email/components@0.0.22'
|
import { renderAsync } from 'npm:@react-email/components@0.0.22'
|
||||||
import { parseEmailWebhookPayload } from 'npm:@lovable.dev/email-js'
|
import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
|
||||||
import { WebhookError, verifyWebhookRequest } from 'npm:@lovable.dev/webhooks-js'
|
|
||||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||||
import { SignupEmail } from '../_shared/email-templates/signup.tsx'
|
import { SignupEmail } from '../_shared/email-templates/signup.tsx'
|
||||||
import { InviteEmail } from '../_shared/email-templates/invite.tsx'
|
import { InviteEmail } from '../_shared/email-templates/invite.tsx'
|
||||||
@@ -10,10 +9,20 @@ import { RecoveryEmail } from '../_shared/email-templates/recovery.tsx'
|
|||||||
import { EmailChangeEmail } from '../_shared/email-templates/email-change.tsx'
|
import { EmailChangeEmail } from '../_shared/email-templates/email-change.tsx'
|
||||||
import { ReauthenticationEmail } from '../_shared/email-templates/reauthentication.tsx'
|
import { ReauthenticationEmail } from '../_shared/email-templates/reauthentication.tsx'
|
||||||
|
|
||||||
|
// This is the Supabase Auth "Send Email" hook. Supabase Auth POSTs a
|
||||||
|
// Standard Webhooks-signed payload here whenever it needs to send an auth email
|
||||||
|
// (signup confirmation, password reset, invite, magic link, email change,
|
||||||
|
// reauthentication). We verify the signature, render the matching branded
|
||||||
|
// template, and enqueue it for the dispatcher (process-email-queue) to send.
|
||||||
|
//
|
||||||
|
// Migrated off Lovable: verification now uses the native Supabase hook secret
|
||||||
|
// (SEND_EMAIL_HOOK_SECRET, format "v1,whsec_<base64>") via the standardwebhooks
|
||||||
|
// library — no @lovable.dev/* dependencies.
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Headers':
|
'Access-Control-Allow-Headers':
|
||||||
'authorization, x-client-info, apikey, content-type, x-lovable-signature, x-lovable-timestamp, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version',
|
'authorization, x-client-info, apikey, content-type, webhook-id, webhook-timestamp, webhook-signature',
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMAIL_SUBJECTS: Record<string, string> = {
|
const EMAIL_SUBJECTS: Record<string, string> = {
|
||||||
@@ -25,7 +34,6 @@ const EMAIL_SUBJECTS: Record<string, string> = {
|
|||||||
reauthentication: 'Your verification code',
|
reauthentication: 'Your verification code',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template mapping
|
|
||||||
const EMAIL_TEMPLATES: Record<string, React.ComponentType<any>> = {
|
const EMAIL_TEMPLATES: Record<string, React.ComponentType<any>> = {
|
||||||
signup: SignupEmail,
|
signup: SignupEmail,
|
||||||
invite: InviteEmail,
|
invite: InviteEmail,
|
||||||
@@ -35,209 +43,93 @@ const EMAIL_TEMPLATES: Record<string, React.ComponentType<any>> = {
|
|||||||
reauthentication: ReauthenticationEmail,
|
reauthentication: ReauthenticationEmail,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration
|
const SITE_NAME = 'Avria Community Management, LLC'
|
||||||
const SITE_NAME = "Avria Community Management, LLC"
|
const ROOT_DOMAIN = 'avria.cloud'
|
||||||
const SENDER_DOMAIN = "notify.avriamail.com"
|
const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" <noreply@${ROOT_DOMAIN}>`
|
||||||
const ROOT_DOMAIN = "avria.cloud"
|
|
||||||
const FROM_DOMAIN = "notify.avriamail.com" // Domain shown in From address (may be root or sender subdomain)
|
|
||||||
const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" <noreply@${FROM_DOMAIN}>`
|
|
||||||
|
|
||||||
// Sample data for preview mode ONLY (not used in actual email sending).
|
// Normalize GoTrue email_action_type to our template keys.
|
||||||
// URLs are baked in at scaffold time from the project's real data.
|
function normalizeActionType(actionType: string): string {
|
||||||
// The sample email uses a fixed placeholder (RFC 6761 .test TLD) so the Go backend
|
if (actionType.startsWith('email_change')) return 'email_change'
|
||||||
// can always find-and-replace it with the actual recipient when sending test emails,
|
return actionType
|
||||||
// even if the project's domain has changed since the template was scaffolded.
|
|
||||||
const SAMPLE_PROJECT_URL = "https://avria.cloud"
|
|
||||||
const SAMPLE_EMAIL = "user@example.test"
|
|
||||||
const SAMPLE_DATA: Record<string, object> = {
|
|
||||||
signup: {
|
|
||||||
siteName: SITE_NAME,
|
|
||||||
siteUrl: SAMPLE_PROJECT_URL,
|
|
||||||
recipient: SAMPLE_EMAIL,
|
|
||||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
|
||||||
},
|
|
||||||
magiclink: {
|
|
||||||
siteName: SITE_NAME,
|
|
||||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
|
||||||
},
|
|
||||||
recovery: {
|
|
||||||
siteName: SITE_NAME,
|
|
||||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
|
||||||
},
|
|
||||||
invite: {
|
|
||||||
siteName: SITE_NAME,
|
|
||||||
siteUrl: SAMPLE_PROJECT_URL,
|
|
||||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
|
||||||
},
|
|
||||||
email_change: {
|
|
||||||
siteName: SITE_NAME,
|
|
||||||
email: SAMPLE_EMAIL,
|
|
||||||
newEmail: SAMPLE_EMAIL,
|
|
||||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
|
||||||
},
|
|
||||||
reauthentication: {
|
|
||||||
token: '123456',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview endpoint handler - returns rendered HTML without sending email
|
// Build the GoTrue verification URL from the hashed token. Hitting this URL
|
||||||
async function handlePreview(req: Request): Promise<Response> {
|
// consumes the token and then redirects the user to redirect_to.
|
||||||
const previewCorsHeaders = {
|
function buildConfirmationUrl(supabaseUrl: string, emailData: any): string {
|
||||||
'Access-Control-Allow-Origin': '*',
|
const params = new URLSearchParams({
|
||||||
'Access-Control-Allow-Headers': 'authorization, content-type',
|
token: emailData.token_hash ?? '',
|
||||||
}
|
type: emailData.email_action_type ?? '',
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return new Response(null, { headers: previewCorsHeaders })
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
|
||||||
const authHeader = req.headers.get('Authorization')
|
|
||||||
|
|
||||||
if (!apiKey || authHeader !== `Bearer ${apiKey}`) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
})
|
})
|
||||||
|
if (emailData.redirect_to) params.set('redirect_to', emailData.redirect_to)
|
||||||
|
return `${supabaseUrl.replace(/\/$/, '')}/auth/v1/verify?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
let type: string
|
|
||||||
try {
|
|
||||||
const body = await req.json()
|
|
||||||
type = body.type
|
|
||||||
} catch (error) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Invalid JSON in request body' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const EmailTemplate = EMAIL_TEMPLATES[type]
|
|
||||||
|
|
||||||
if (!EmailTemplate) {
|
|
||||||
return new Response(JSON.stringify({ error: `Unknown email type: ${type}` }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const sampleData = SAMPLE_DATA[type] || {}
|
|
||||||
const html = await renderAsync(React.createElement(EmailTemplate, sampleData))
|
|
||||||
|
|
||||||
return new Response(html, {
|
|
||||||
status: 200,
|
|
||||||
headers: { ...previewCorsHeaders, 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Webhook handler - verifies signature and sends email
|
|
||||||
async function handleWebhook(req: Request): Promise<Response> {
|
async function handleWebhook(req: Request): Promise<Response> {
|
||||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
const hookSecret = Deno.env.get('SEND_EMAIL_HOOK_SECRET')
|
||||||
|
if (!hookSecret) {
|
||||||
if (!apiKey) {
|
console.error('SEND_EMAIL_HOOK_SECRET not configured')
|
||||||
console.error('LOVABLE_API_KEY not configured')
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Server configuration error' }),
|
JSON.stringify({ error: 'Server configuration error' }),
|
||||||
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature + timestamp, then parse payload.
|
const payloadRaw = await req.text()
|
||||||
let payload: any
|
const headers = Object.fromEntries(req.headers)
|
||||||
let run_id = ''
|
|
||||||
|
// Verify the Standard Webhooks signature. The library expects the base64
|
||||||
|
// secret without the "v1,whsec_" prefix Supabase displays.
|
||||||
|
let verified: any
|
||||||
try {
|
try {
|
||||||
const verified = await verifyWebhookRequest({
|
const wh = new Webhook(hookSecret.replace(/^v1,whsec_/, ''))
|
||||||
req,
|
verified = wh.verify(payloadRaw, headers)
|
||||||
secret: apiKey,
|
|
||||||
parser: parseEmailWebhookPayload,
|
|
||||||
})
|
|
||||||
payload = verified.payload
|
|
||||||
run_id = payload.run_id
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WebhookError) {
|
console.error('Auth hook signature verification failed', {
|
||||||
switch (error.code) {
|
error: error instanceof Error ? error.message : String(error),
|
||||||
case 'invalid_signature':
|
|
||||||
case 'missing_timestamp':
|
|
||||||
case 'invalid_timestamp':
|
|
||||||
case 'stale_timestamp':
|
|
||||||
console.error('Invalid webhook signature', { error: error.message })
|
|
||||||
return new Response(JSON.stringify({ error: 'Invalid signature' }), {
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
})
|
})
|
||||||
case 'invalid_payload':
|
|
||||||
case 'invalid_json':
|
|
||||||
console.error('Invalid webhook payload', { error: error.message })
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
JSON.stringify({ error: 'Invalid signature' }),
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Webhook verification failed', { error })
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!run_id) {
|
const user = verified.user
|
||||||
console.error('Webhook payload missing run_id')
|
const emailData = verified.email_data
|
||||||
return new Response(
|
const recipient: string | undefined = user?.email
|
||||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
const rawActionType: string = emailData?.email_action_type ?? ''
|
||||||
{
|
const emailType = normalizeActionType(rawActionType)
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.version !== '1') {
|
console.log('Received auth send-email hook', { emailType, rawActionType, recipient })
|
||||||
console.error('Unsupported payload version', { version: payload.version, run_id })
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: `Unsupported payload version: ${payload.version}` }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The email action type is in payload.data.action_type (e.g., "signup", "recovery")
|
|
||||||
// payload.type is the hook event type ("auth")
|
|
||||||
const emailType = payload.data.action_type
|
|
||||||
console.log('Received auth event', { emailType, email: payload.data.email, run_id })
|
|
||||||
|
|
||||||
const EmailTemplate = EMAIL_TEMPLATES[emailType]
|
const EmailTemplate = EMAIL_TEMPLATES[emailType]
|
||||||
if (!EmailTemplate) {
|
if (!EmailTemplate || !recipient) {
|
||||||
console.error('Unknown email type', { emailType, run_id })
|
console.error('Unknown email type or missing recipient', { emailType, hasRecipient: !!recipient })
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: `Unknown email type: ${emailType}` }),
|
JSON.stringify({ error: `Unsupported email action type: ${rawActionType}` }),
|
||||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build template props from payload.data (HookData structure)
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||||
|
const confirmationUrl = buildConfirmationUrl(supabaseUrl, emailData)
|
||||||
|
|
||||||
const templateProps = {
|
const templateProps = {
|
||||||
siteName: SITE_NAME,
|
siteName: SITE_NAME,
|
||||||
siteUrl: `https://${ROOT_DOMAIN}`,
|
siteUrl: `https://${ROOT_DOMAIN}`,
|
||||||
recipient: payload.data.email,
|
recipient,
|
||||||
confirmationUrl: payload.data.url,
|
confirmationUrl,
|
||||||
token: payload.data.token,
|
token: emailData?.token,
|
||||||
email: payload.data.email,
|
email: recipient,
|
||||||
newEmail: payload.data.new_email,
|
newEmail: user?.new_email ?? recipient,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render React Email to HTML and plain text
|
|
||||||
const html = await renderAsync(React.createElement(EmailTemplate, templateProps))
|
const html = await renderAsync(React.createElement(EmailTemplate, templateProps))
|
||||||
const text = await renderAsync(React.createElement(EmailTemplate, templateProps), {
|
const text = await renderAsync(React.createElement(EmailTemplate, templateProps), {
|
||||||
plainText: true,
|
plainText: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Enqueue email for async processing by the dispatcher (process-email-queue).
|
|
||||||
const supabase = createClient(
|
const supabase = createClient(
|
||||||
Deno.env.get('SUPABASE_URL')!,
|
supabaseUrl,
|
||||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,18 +139,16 @@ async function handleWebhook(req: Request): Promise<Response> {
|
|||||||
await supabase.from('email_send_log').insert({
|
await supabase.from('email_send_log').insert({
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
template_name: emailType,
|
template_name: emailType,
|
||||||
recipient_email: payload.data.email,
|
recipient_email: recipient,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { error: enqueueError } = await supabase.rpc('enqueue_email', {
|
const { error: enqueueError } = await supabase.rpc('enqueue_email', {
|
||||||
queue_name: 'auth_emails',
|
queue_name: 'auth_emails',
|
||||||
payload: {
|
payload: {
|
||||||
run_id,
|
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
to: payload.data.email,
|
to: recipient,
|
||||||
from: FROM_ADDRESS,
|
from: FROM_ADDRESS,
|
||||||
sender_domain: SENDER_DOMAIN,
|
|
||||||
subject: EMAIL_SUBJECTS[emailType] || 'Notification',
|
subject: EMAIL_SUBJECTS[emailType] || 'Notification',
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
@@ -269,11 +159,11 @@ async function handleWebhook(req: Request): Promise<Response> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (enqueueError) {
|
if (enqueueError) {
|
||||||
console.error('Failed to enqueue auth email', { error: enqueueError, run_id, emailType })
|
console.error('Failed to enqueue auth email', { error: enqueueError, emailType })
|
||||||
await supabase.from('email_send_log').insert({
|
await supabase.from('email_send_log').insert({
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
template_name: emailType,
|
template_name: emailType,
|
||||||
recipient_email: payload.data.email,
|
recipient_email: recipient,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error_message: 'Failed to enqueue email',
|
error_message: 'Failed to enqueue email',
|
||||||
})
|
})
|
||||||
@@ -283,32 +173,23 @@ async function handleWebhook(req: Request): Promise<Response> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Auth email enqueued', { emailType, email: payload.data.email, run_id })
|
console.log('Auth email enqueued', { emailType, recipient })
|
||||||
|
|
||||||
|
// Supabase expects a 200 with an empty/again-acknowledged body on success.
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ success: true, queued: true }),
|
JSON.stringify({}),
|
||||||
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
const url = new URL(req.url)
|
|
||||||
|
|
||||||
// Handle CORS preflight for main endpoint
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return new Response(null, { headers: corsHeaders })
|
return new Response(null, { headers: corsHeaders })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to preview handler for /preview path
|
|
||||||
if (url.pathname.endsWith('/preview')) {
|
|
||||||
return handlePreview(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main webhook handler
|
|
||||||
try {
|
try {
|
||||||
return await handleWebhook(req)
|
return await handleWebhook(req)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Webhook handler error:', error)
|
console.error('Auth hook handler error:', error)
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
return new Response(JSON.stringify({ error: message }), {
|
return new Response(JSON.stringify({ error: message }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|||||||
@@ -1,5 +1,47 @@
|
|||||||
import { sendLovableEmail } from 'npm:@lovable.dev/email-js'
|
|
||||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||||
|
import { sendSmtpMessage, type SmtpSender } from '../_shared/smtp-send.ts'
|
||||||
|
import { sendViaHostingerMail, type HostingerMailConfig } from '../_shared/hostinger-mail.ts'
|
||||||
|
|
||||||
|
// Which sender identity automated email is sent from. Must be an active row in
|
||||||
|
// public.email_senders that the SMTP account is authorized to send as. Override
|
||||||
|
// per-environment with the AUTOMATED_EMAIL_FROM secret.
|
||||||
|
// NOTE: no-reply@avriamail.com (Office365) is the sender with currently-valid
|
||||||
|
// SMTP credentials — it is the address that has actually been delivering mail.
|
||||||
|
// The Hostinger mail@avriacam.com mailbox rejects its stored password (SMTP 535)
|
||||||
|
// and must have its password re-entered in Email Settings before it can be used.
|
||||||
|
const DEFAULT_AUTOMATED_FROM = 'no-reply@avriamail.com'
|
||||||
|
|
||||||
|
// Load (and shape) the SMTP sender used for all queued automated email.
|
||||||
|
async function loadAutomatedSender(
|
||||||
|
supabase: ReturnType<typeof createClient>
|
||||||
|
): Promise<SmtpSender> {
|
||||||
|
const fromEmail = Deno.env.get('AUTOMATED_EMAIL_FROM') || DEFAULT_AUTOMATED_FROM
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('email_senders')
|
||||||
|
.select('sender_name, email_address, smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl')
|
||||||
|
.eq('is_active', true)
|
||||||
|
.eq('email_address', fromEmail)
|
||||||
|
.order('verified', { ascending: false })
|
||||||
|
.order('is_default', { ascending: false })
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (error) throw new Error(`Failed to load SMTP sender '${fromEmail}': ${error.message}`)
|
||||||
|
if (!data) throw new Error(`No active email_senders row for '${fromEmail}'`)
|
||||||
|
|
||||||
|
const port = Number(data.smtp_port ?? 587)
|
||||||
|
return {
|
||||||
|
host: data.smtp_host as string,
|
||||||
|
port,
|
||||||
|
username: data.smtp_username as string,
|
||||||
|
password: data.smtp_password as string,
|
||||||
|
use_ssl: port === 465 ? true : Boolean(data.use_ssl),
|
||||||
|
use_tls: port === 465 ? false : (data.use_tls ?? port === 587),
|
||||||
|
fromEmail: data.email_address as string,
|
||||||
|
fromName: (data.sender_name as string) || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_RETRIES = 5
|
const MAX_RETRIES = 5
|
||||||
const DEFAULT_BATCH_SIZE = 10
|
const DEFAULT_BATCH_SIZE = 10
|
||||||
@@ -79,11 +121,10 @@ async function moveToDlq(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
|
||||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')
|
||||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
|
||||||
|
|
||||||
if (!apiKey || !supabaseUrl || !supabaseServiceKey) {
|
if (!supabaseUrl || !supabaseServiceKey) {
|
||||||
console.error('Missing required environment variables')
|
console.error('Missing required environment variables')
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Server configuration error' }),
|
JSON.stringify({ error: 'Server configuration error' }),
|
||||||
@@ -113,6 +154,31 @@ Deno.serve(async (req) => {
|
|||||||
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||||
|
|
||||||
|
// Resolve the SMTP sender once per run (shared across the whole batch).
|
||||||
|
let smtpSender: SmtpSender
|
||||||
|
try {
|
||||||
|
smtpSender = await loadAutomatedSender(supabase)
|
||||||
|
} catch (senderErr) {
|
||||||
|
console.error('Cannot load automated email sender', { error: senderErr })
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Email sender not configured', detail: String(senderErr) }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base URL for the one-click List-Unsubscribe link.
|
||||||
|
const unsubscribeBase = `${supabaseUrl.replace(/\/$/, '')}/functions/v1/handle-email-unsubscribe`
|
||||||
|
|
||||||
|
// Preferred transport: Hostinger Email API (when configured via secrets).
|
||||||
|
// Falls back to SMTP automatically on any API error, so there is no outage
|
||||||
|
// while the secrets are being set or if the API has a transient failure.
|
||||||
|
const hostingerToken = Deno.env.get('HOSTINGER_MAIL_API_TOKEN')
|
||||||
|
const hostingerResourceId = Deno.env.get('HOSTINGER_MAIL_RESOURCE_ID')
|
||||||
|
const hostinger: HostingerMailConfig | null =
|
||||||
|
hostingerToken && hostingerResourceId
|
||||||
|
? { token: hostingerToken, mailboxResourceId: hostingerResourceId }
|
||||||
|
: null
|
||||||
|
|
||||||
// 1. Check rate-limit cooldown and read queue config
|
// 1. Check rate-limit cooldown and read queue config
|
||||||
const { data: state } = await supabase
|
const { data: state } = await supabase
|
||||||
.from('email_send_state')
|
.from('email_send_state')
|
||||||
@@ -249,26 +315,38 @@ Deno.serve(async (req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendLovableEmail(
|
// Preferred: Hostinger Email API. Fall back to the project's own SMTP
|
||||||
{
|
// sender (Lovable-free) on any API error so delivery keeps flowing.
|
||||||
run_id: payload.run_id,
|
let sentVia = 'smtp'
|
||||||
to: payload.to,
|
const sendSmtp = () => sendSmtpMessage(smtpSender, {
|
||||||
from: payload.from,
|
to: payload.to as string,
|
||||||
sender_domain: payload.sender_domain,
|
subject: payload.subject as string,
|
||||||
subject: payload.subject,
|
html: payload.html as string,
|
||||||
html: payload.html,
|
text: payload.text as string | undefined,
|
||||||
text: payload.text,
|
unsubscribeUrl: payload.unsubscribe_token
|
||||||
purpose: payload.purpose,
|
? `${unsubscribeBase}?token=${payload.unsubscribe_token}`
|
||||||
label: payload.label,
|
: undefined,
|
||||||
idempotency_key: payload.idempotency_key,
|
})
|
||||||
unsubscribe_token: payload.unsubscribe_token,
|
|
||||||
message_id: payload.message_id,
|
if (hostinger) {
|
||||||
},
|
try {
|
||||||
// sendUrl is optional — when LOVABLE_SEND_URL is not set, the library
|
await sendViaHostingerMail(hostinger, {
|
||||||
// falls back to the default Lovable API endpoint (https://api.lovable.dev).
|
to: payload.to as string,
|
||||||
// Set LOVABLE_SEND_URL as a Supabase secret to override (e.g. for local dev).
|
subject: payload.subject as string,
|
||||||
{ apiKey, sendUrl: Deno.env.get('LOVABLE_SEND_URL') }
|
html: payload.html as string,
|
||||||
)
|
text: payload.text as string | undefined,
|
||||||
|
})
|
||||||
|
sentVia = 'hostinger-api'
|
||||||
|
} catch (apiErr) {
|
||||||
|
console.warn('Hostinger API send failed — falling back to SMTP', {
|
||||||
|
msg_id: msg.msg_id,
|
||||||
|
error: apiErr instanceof Error ? apiErr.message : String(apiErr),
|
||||||
|
})
|
||||||
|
await sendSmtp()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await sendSmtp()
|
||||||
|
}
|
||||||
|
|
||||||
// Log success
|
// Log success
|
||||||
await supabase.from('email_send_log').insert({
|
await supabase.from('email_send_log').insert({
|
||||||
@@ -276,6 +354,7 @@ Deno.serve(async (req) => {
|
|||||||
template_name: payload.label || queue,
|
template_name: payload.label || queue,
|
||||||
recipient_email: payload.to,
|
recipient_email: payload.to,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
error_message: `via:${sentVia}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete from queue
|
// Delete from queue
|
||||||
|
|||||||
Reference in New Issue
Block a user