Files
LexiChain/lib/services/email.service.ts
2026-05-10 18:25:58 +01:00

513 lines
20 KiB
TypeScript

import nodemailer from "nodemailer";
interface ContractBlueprint {
type: string;
provider: string | null;
policyNumber: string | null;
startDate: string | null;
endDate: string | null;
premium: number | null;
premiumCurrency: string | null;
summary: string;
}
interface BlockchainEmailData {
documentHash: string;
txHash: string;
blockNumber: number;
blockTimestamp: Date;
network: string;
contractAddress: string;
explorerUrl: string | null;
}
interface ContractAnalysisEmailInput {
to: string;
userDisplayName?: string | null;
contractId: string;
contractFileName: string;
contractTitle: string;
blueprint: ContractBlueprint;
blockchain?: BlockchainEmailData | null;
}
interface ContractDeadlineEmailInput {
to: string;
userDisplayName?: string | null;
contractId: string;
contractTitle: string | null;
contractProvider: string | null;
contractEndDate: Date;
daysUntilExpiration: number;
}
let transporter: nodemailer.Transporter | null = null;
let transportMode: "smtp" | "ethereal" | null = null;
let hasWarnedMissingEmailConfig = false;
const asBoolean = (value: string | undefined, fallback: boolean): boolean => {
if (!value) return fallback;
return value.toLowerCase() === "true" || value === "1";
};
const isEmailConfigured = (): boolean => {
return Boolean(
process.env.EMAIL_HOST &&
process.env.EMAIL_PORT &&
process.env.EMAIL_USER &&
process.env.EMAIL_PASS,
);
};
const warnMissingEmailConfigOnce = () => {
if (hasWarnedMissingEmailConfig) return;
hasWarnedMissingEmailConfig = true;
console.warn(
"Email notifications are disabled. Configure EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, and MAIL_FROM to enable contract summary emails.",
);
};
const getTransporter = async (): Promise<nodemailer.Transporter | null> => {
if (transporter) {
return transporter;
}
if (isEmailConfigured()) {
transportMode = "smtp";
transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT),
secure: asBoolean(
process.env.EMAIL_SECURE,
Number(process.env.EMAIL_PORT) === 465,
),
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
return transporter;
}
if (process.env.NODE_ENV !== "production") {
const testAccount = await nodemailer.createTestAccount();
transportMode = "ethereal";
transporter = nodemailer.createTransport({
host: testAccount.smtp.host,
port: testAccount.smtp.port,
secure: testAccount.smtp.secure,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
console.warn(
"Email service is running in development fallback mode using Ethereal. Configure SMTP env vars for real inbox delivery.",
);
return transporter;
}
warnMissingEmailConfigOnce();
return null;
};
const formatPremium = (
premium: number | null,
currency: string | null,
): string => {
if (premium === null || premium === undefined) return "N/A";
const formattedAmount = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(premium);
if (!currency) return formattedAmount;
if (["€", "$", "£"].includes(currency))
return `${currency}${formattedAmount}`;
return `${formattedAmount} ${currency}`;
};
const formatDateValue = (dateValue: string | null): string => {
if (!dateValue) return "N/A";
const date = new Date(dateValue);
if (Number.isNaN(date.getTime())) return dateValue;
return date.toISOString().split("T")[0];
};
const formatContractLink = (contractId: string): string | null => {
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
if (!baseUrl) return null;
return `${baseUrl.replace(/\/$/, "")}/contacts?contract=${contractId}`;
};
const getBaseUrl = (): string | null => {
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.APP_URL?.trim();
return baseUrl ? baseUrl.replace(/\/$/, "") : null;
};
const getLogoUrl = (): string | null => {
const baseUrl = getBaseUrl();
return baseUrl ? `${baseUrl}/LexiChain.png` : null;
};
export class EmailService {
static async sendContractAnalysisCompletedEmail(
input: ContractAnalysisEmailInput,
): Promise<{
success: boolean;
error?: string;
skipped?: boolean;
previewUrl?: string | null;
}> {
try {
const mailer = await getTransporter();
if (!mailer) {
return {
success: false,
skipped: true,
error: "Email service not configured",
};
}
const from =
process.env.MAIL_FROM?.trim() ||
process.env.EMAIL_USER?.trim() ||
(transportMode === "ethereal"
? "LexiChain <no-reply@ethereal.email>"
: "");
if (!from) {
warnMissingEmailConfigOnce();
return { success: false, skipped: true, error: "MAIL_FROM is missing" };
}
if (!input.to?.trim()) {
return {
success: false,
skipped: true,
error: "Recipient email is missing",
};
}
const recipientName = input.userDisplayName || "there";
const premiumLabel = formatPremium(
input.blueprint.premium,
input.blueprint.premiumCurrency,
);
const contractUrl = formatContractLink(input.contractId);
const logoUrl = getLogoUrl();
const blockchainStatus = input.blockchain
? "Registered"
: "Not registered (blockchain unavailable or skipped)";
const textBody = [
`Hello ${recipientName},`,
"",
"Your LexiChain contract intelligence report is ready.",
"",
"Blueprint:",
`- Contract title: ${input.contractTitle}`,
`- Original file: ${input.contractFileName}`,
`- Type: ${input.blueprint.type}`,
`- Provider: ${input.blueprint.provider ?? "N/A"}`,
`- Policy number: ${input.blueprint.policyNumber ?? "N/A"}`,
`- Start date: ${formatDateValue(input.blueprint.startDate)}`,
`- End date: ${formatDateValue(input.blueprint.endDate)}`,
`- Premium: ${premiumLabel}`,
"",
"Executive summary:",
input.blueprint.summary,
"",
"Blockchain proof:",
`- Status: ${blockchainStatus}`,
`- Document hash: ${input.blockchain?.documentHash ?? "N/A"}`,
`- Transaction hash: ${input.blockchain?.txHash ?? "N/A"}`,
`- Block number: ${input.blockchain?.blockNumber ?? "N/A"}`,
`- Block time: ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}`,
`- Network: ${input.blockchain?.network ?? "N/A"}`,
`- Contract address: ${input.blockchain?.contractAddress ?? "N/A"}`,
`- Explorer URL: ${input.blockchain?.explorerUrl ?? "N/A"}`,
"",
contractUrl ? `Open in app: ${contractUrl}` : "",
"",
"Thank you for trusting LexiChain for secure contract governance.",
]
.filter(Boolean)
.join("\n");
const htmlBody = `
<div style="margin:0;padding:0;background:#0f172a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
<tr>
<td style="padding:24px 28px;background:linear-gradient(135deg,#0f172a 0%,#1d4ed8 100%);color:#ffffff;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="vertical-align:middle;">
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="140" style="display:block;border-radius:10px;" />` : '<strong style="font-size:20px;">LexiChain</strong>'}
</td>
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
Contract Intelligence
</td>
</tr>
</table>
<h1 style="margin:20px 0 6px;font-size:26px;">Your contract insight report is ready</h1>
<p style="margin:0;font-size:14px;opacity:0.9;">Clear, verified, and ready for action.</p>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 16px;font-size:14px;">Hello ${recipientName},</p>
<p style="margin:0 0 20px;font-size:14px;color:#334155;">
Your LexiChain analysis is complete. Below is the executive blueprint and proof trace.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
<tr>
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Blueprint Summary</td>
</tr>
<tr>
<td style="font-size:13px;color:#475569;">
<div><strong>Contract:</strong> ${input.contractTitle}</div>
<div><strong>File:</strong> ${input.contractFileName}</div>
<div><strong>Type:</strong> ${input.blueprint.type}</div>
<div><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</div>
<div><strong>Policy #:</strong> ${input.blueprint.policyNumber ?? "N/A"}</div>
<div><strong>Start:</strong> ${formatDateValue(input.blueprint.startDate)}</div>
<div><strong>End:</strong> ${formatDateValue(input.blueprint.endDate)}</div>
<div><strong>Premium:</strong> ${premiumLabel}</div>
</td>
</tr>
</table>
<div style="margin:18px 0 8px;font-size:13px;font-weight:bold;">Executive Summary</div>
<p style="margin:0 0 18px;font-size:13px;color:#475569;line-height:1.6;">${input.blueprint.summary.replace(/\n/g, "<br />")}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2ff;border:1px solid #c7d2fe;border-radius:14px;padding:16px;">
<tr>
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;color:#1e3a8a;">Blockchain Proof</td>
</tr>
<tr>
<td style="font-size:12px;color:#1e3a8a;line-height:1.6;">
<div><strong>Status:</strong> ${blockchainStatus}</div>
<div><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</div>
<div><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</div>
<div><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</div>
<div><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</div>
<div><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</div>
<div><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</div>
<div><strong>Explorer:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;text-decoration:none;">Open transaction</a>` : "N/A"}</div>
</td>
</tr>
</table>
${
contractUrl
? `
<div style="margin-top:22px;">
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Open in LexiChain</a>
</div>
`
: ""
}
<p style="margin:20px 0 0;font-size:12px;color:#94a3b8;">Precision you can audit. Trust you can prove.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`;
const info = await mailer.sendMail({
from,
to: input.to,
subject: `Contract analyzed: ${input.contractTitle}`,
html: htmlBody,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
const previewUrl =
(nodemailer.getTestMessageUrl(info) as string | false) || null;
if (previewUrl) {
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
}
return { success: true, previewUrl };
} catch (error) {
console.error("Failed to send analysis completion email:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown email error",
};
}
}
static async sendContractDeadlineReminderEmail(
input: ContractDeadlineEmailInput,
): Promise<{
success: boolean;
error?: string;
skipped?: boolean;
previewUrl?: string | null;
}> {
try {
const mailer = await getTransporter();
if (!mailer) {
return {
success: false,
skipped: true,
error: "Email service not configured",
};
}
const from =
process.env.MAIL_FROM?.trim() ||
process.env.EMAIL_USER?.trim() ||
(transportMode === "ethereal"
? "LexiChain <no-reply@ethereal.email>"
: "");
if (!from) {
warnMissingEmailConfigOnce();
return { success: false, skipped: true, error: "MAIL_FROM is missing" };
}
if (!input.to?.trim()) {
return {
success: false,
skipped: true,
error: "Recipient email is missing",
};
}
const recipientName = input.userDisplayName || "there";
const contractUrl = formatContractLink(input.contractId);
const logoUrl = getLogoUrl();
const endDate = input.contractEndDate.toLocaleDateString();
const urgencyLabel =
input.daysUntilExpiration <= 7
? "Urgent"
: input.daysUntilExpiration <= 14
? "High"
: "Planned";
const textBody = [
`Hello ${recipientName},`,
"",
`Your contract deadline is approaching in ${input.daysUntilExpiration} days.`,
"",
`Contract: ${input.contractTitle ?? "Untitled contract"}`,
`Provider: ${input.contractProvider ?? "N/A"}`,
`End date: ${endDate}`,
`Urgency: ${urgencyLabel}`,
"",
contractUrl ? `Open in app: ${contractUrl}` : "",
"",
"Please review the contract and schedule renewal if needed.",
]
.filter(Boolean)
.join("\n");
const htmlBody = `
<div style="margin:0;padding:0;background:#0f172a;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#0f172a;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="background:#ffffff;border-radius:20px;overflow:hidden;font-family:Arial, sans-serif;color:#0f172a;">
<tr>
<td style="padding:22px 28px;background:linear-gradient(135deg,#0f172a 0%,#2563eb 100%);color:#ffffff;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="left" style="vertical-align:middle;">
${logoUrl ? `<img src="${logoUrl}" alt="LexiChain" width="130" style="display:block;border-radius:10px;" />` : '<strong style="font-size:18px;">LexiChain</strong>'}
</td>
<td align="right" style="vertical-align:middle;font-size:12px;letter-spacing:1.4px;text-transform:uppercase;opacity:0.85;">
Deadline Alert
</td>
</tr>
</table>
<h1 style="margin:18px 0 6px;font-size:24px;">Contract renewal reminder</h1>
<p style="margin:0;font-size:14px;opacity:0.9;">Stay ahead of critical dates.</p>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 14px;font-size:14px;">Hello ${recipientName},</p>
<p style="margin:0 0 18px;font-size:14px;color:#334155;">
Your contract deadline is approaching in <strong>${input.daysUntilExpiration} days</strong>.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;padding:16px;">
<tr>
<td style="font-size:13px;font-weight:bold;padding-bottom:12px;">Deadline Summary</td>
</tr>
<tr>
<td style="font-size:13px;color:#475569;line-height:1.6;">
<div><strong>Contract:</strong> ${input.contractTitle ?? "Untitled contract"}</div>
<div><strong>Provider:</strong> ${input.contractProvider ?? "N/A"}</div>
<div><strong>End date:</strong> ${endDate}</div>
<div><strong>Urgency:</strong> ${urgencyLabel}</div>
</td>
</tr>
</table>
${
contractUrl
? `
<div style="margin-top:20px;">
<a href="${contractUrl}" style="display:inline-block;background:#1d4ed8;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:10px;font-weight:bold;font-size:13px;">Review in LexiChain</a>
</div>
`
: ""
}
<p style="margin:18px 0 0;font-size:12px;color:#94a3b8;">Plan renewals early to avoid coverage gaps.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`;
const info = await mailer.sendMail({
from,
to: input.to,
subject: `Contract deadline in ${input.daysUntilExpiration} days`,
html: htmlBody,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
const previewUrl =
(nodemailer.getTestMessageUrl(info) as string | false) || null;
if (previewUrl) {
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
}
return { success: true, previewUrl };
} catch (error) {
console.error("Failed to send deadline reminder email:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown email error",
};
}
}
}