Files
Momento/memento-note/lib/mail.ts
Sepehr Ramezani d1cda126d8 fix: auto-tagging provider resolution, email test flow, language detection
Auto-tagging:
- CRITICAL FIX: contextual-auto-tag.service.ts was calling getAIProvider()
  (alias for getEmbeddingsProvider) instead of getTagsProvider(). This meant
  auto-tagging used the embeddings provider/model instead of the tags one.
  Now correctly uses getTagsProvider() in both suggestFromExistingLabels and
  suggestNewLabels methods.
- Pass user's detected language to suggestLabels() for localized prompts
  (was hardcoded to 'en')

Email:
- Fix Resend "from" field: pass DB config to sendViaResend() instead of
  re-fetching from DB. Uses SMTP_FROM from config, with localhost-aware fallback.
- Add "Sender email" field in admin Resend section so users can set SMTP_FROM
- Save SMTP_FROM when Resend is selected (was only saved for SMTP mode)
- Test email button now saves config to DB BEFORE testing, so unsaved form
  values are used (was reading stale DB values)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-21 22:22:02 +02:00

162 lines
4.9 KiB
TypeScript

import nodemailer from 'nodemailer';
import { getSystemConfig } from './config';
export interface InlineAttachment {
filename: string
content: Buffer
cid: string
}
interface MailOptions {
to: string;
subject: string;
html: string;
attachments?: InlineAttachment[];
}
interface MailResult {
success: boolean;
messageId?: string;
error?: string;
}
type EmailProvider = 'auto' | 'resend' | 'smtp';
/**
* Send email.
* - 'auto': try Resend first (if key set), fall back to SMTP on failure
* - 'smtp': force SMTP only
* - 'resend': force Resend only
* Supports inline image attachments via cid: references in HTML.
*/
export async function sendEmail({ to, subject, html, attachments }: MailOptions, provider: EmailProvider = 'auto'): Promise<MailResult> {
const config = await getSystemConfig();
const resendKey = config.RESEND_API_KEY || process.env.RESEND_API_KEY;
// Force SMTP
if (provider === 'smtp') {
return sendViaSMTP(config, { to, subject, html, attachments });
}
// Force Resend (no fallback)
if (provider === 'resend') {
if (!resendKey) return { success: false, error: 'No Resend API key configured' };
return sendViaResend(resendKey, config, { to, subject, html, attachments });
}
// Auto: try Resend, fall back to SMTP
if (resendKey) {
const result = await sendViaResend(resendKey, config, { to, subject, html, attachments });
if (result.success) return result;
console.warn('[Mail] Resend failed, falling back to SMTP:', result.error);
return sendViaSMTP(config, { to, subject, html, attachments });
}
return sendViaSMTP(config, { to, subject, html, attachments });
}
async function sendViaResend(apiKey: string, config: Record<string, string>, { to, subject, html, attachments }: MailOptions): Promise<MailResult> {
try {
const { Resend } = await import('resend');
const resend = new Resend(apiKey);
// Build a valid "from" address for Resend
// Priority: SMTP_FROM from DB config > env var > derived from NEXTAUTH_URL > Resend default
const smtpFrom = config.SMTP_FROM || process.env.SMTP_FROM;
let from: string;
if (smtpFrom) {
from = smtpFrom.includes('<') ? smtpFrom : `Memento <${smtpFrom}>`;
} else if (process.env.NEXTAUTH_URL) {
const hostname = new URL(process.env.NEXTAUTH_URL).hostname;
// Only use hostname-based from if it's not localhost (Resend rejects it)
if (hostname !== 'localhost') {
from = `Memento <noreply@${hostname}>`;
} else {
from = 'Memento <onboarding@resend.dev>';
}
} else {
from = 'Memento <onboarding@resend.dev>';
}
// Resend supports attachments with inline content
const resendAttachments = attachments?.map(att => ({
filename: att.filename,
content: att.content.toString('base64'),
content_type: att.filename.endsWith('.png') ? 'image/png' : 'image/jpeg',
disposition: 'inline' as const,
content_id: att.cid,
}));
const { data, error } = await resend.emails.send({
from,
to,
subject,
html,
attachments: resendAttachments,
});
if (error) {
return { success: false, error: error.message };
}
return { success: true, messageId: data?.id };
} catch (error: any) {
return { success: false, error: `Resend: ${error.message}` };
}
}
async function sendViaSMTP(config: Record<string, string>, { to, subject, html, attachments }: MailOptions): Promise<MailResult> {
const host = config.SMTP_HOST || process.env.SMTP_HOST;
const port = parseInt(config.SMTP_PORT || process.env.SMTP_PORT || '587');
const user = (config.SMTP_USER || process.env.SMTP_USER || '').trim();
const pass = (config.SMTP_PASS || process.env.SMTP_PASS || '').trim();
const from = config.SMTP_FROM || process.env.SMTP_FROM || 'noreply@memento.app';
if (!host) {
return { success: false, error: 'SMTP host is not configured' };
}
const forceSecure = config.SMTP_SECURE === 'true';
const isPort465 = port === 465;
const secure = forceSecure || isPort465;
const ignoreCerts = config.SMTP_IGNORE_CERT === 'true';
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: { user, pass },
family: 4,
authMethod: 'LOGIN',
connectionTimeout: 10000,
tls: {
rejectUnauthorized: !ignoreCerts,
ciphers: ignoreCerts ? 'SSLv3' : undefined
}
} as any);
try {
await transporter.verify();
// Build nodemailer inline attachments with cid
const smtpAttachments = attachments?.map(att => ({
filename: att.filename,
content: att.content,
cid: att.cid,
}));
const info = await transporter.sendMail({
from: `"Memento" <${from}>`,
to,
subject,
html,
attachments: smtpAttachments,
});
return { success: true, messageId: info.messageId };
} catch (error: any) {
console.error('SMTP error:', error);
return { success: false, error: `SMTP: ${error.message} (Code: ${error.code})` };
}
}