This commit is contained in:
2026-05-10 18:25:58 +01:00
parent 165af509ef
commit e4f4992a1b
31 changed files with 2708 additions and 495 deletions

View File

@@ -31,6 +31,16 @@ interface ContractAnalysisEmailInput {
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;
@@ -134,6 +144,17 @@ const formatContractLink = (contractId: string): string | 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,
@@ -178,6 +199,7 @@ export class EmailService {
input.blueprint.premiumCurrency,
);
const contractUrl = formatContractLink(input.contractId);
const logoUrl = getLogoUrl();
const blockchainStatus = input.blockchain
? "Registered"
: "Not registered (blockchain unavailable or skipped)";
@@ -185,7 +207,7 @@ export class EmailService {
const textBody = [
`Hello ${recipientName},`,
"",
"Your contract analysis is complete.",
"Your LexiChain contract intelligence report is ready.",
"",
"Blueprint:",
`- Contract title: ${input.contractTitle}`,
@@ -197,7 +219,7 @@ export class EmailService {
`- End date: ${formatDateValue(input.blueprint.endDate)}`,
`- Premium: ${premiumLabel}`,
"",
"Summary:",
"Executive summary:",
input.blueprint.summary,
"",
"Blockchain proof:",
@@ -212,46 +234,97 @@ export class EmailService {
"",
contractUrl ? `Open in app: ${contractUrl}` : "",
"",
"Keep this email for your records.",
"Thank you for trusting LexiChain for secure contract governance.",
]
.filter(Boolean)
.join("\n");
const htmlBody = `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #0f172a;">
<h2 style="margin-bottom: 12px;">Contract Analysis Completed</h2>
<p>Hello ${recipientName},</p>
<p>Your contract analysis has been completed successfully.</p>
<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>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blueprint</h3>
<ul>
<li><strong>Contract title:</strong> ${input.contractTitle}</li>
<li><strong>Original file:</strong> ${input.contractFileName}</li>
<li><strong>Type:</strong> ${input.blueprint.type}</li>
<li><strong>Provider:</strong> ${input.blueprint.provider ?? "N/A"}</li>
<li><strong>Policy number:</strong> ${input.blueprint.policyNumber ?? "N/A"}</li>
<li><strong>Start date:</strong> ${formatDateValue(input.blueprint.startDate)}</li>
<li><strong>End date:</strong> ${formatDateValue(input.blueprint.endDate)}</li>
<li><strong>Premium:</strong> ${premiumLabel}</li>
</ul>
<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>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Summary</h3>
<p>${input.blueprint.summary.replace(/\n/g, "<br />")}</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>
<h3 style="margin-top: 24px; margin-bottom: 8px;">Blockchain Proof</h3>
<ul>
<li><strong>Status:</strong> ${blockchainStatus}</li>
<li><strong>Document hash:</strong> ${input.blockchain?.documentHash ?? "N/A"}</li>
<li><strong>Transaction hash:</strong> ${input.blockchain?.txHash ?? "N/A"}</li>
<li><strong>Block number:</strong> ${input.blockchain?.blockNumber ?? "N/A"}</li>
<li><strong>Block time:</strong> ${input.blockchain?.blockTimestamp?.toISOString() ?? "N/A"}</li>
<li><strong>Network:</strong> ${input.blockchain?.network ?? "N/A"}</li>
<li><strong>Contract address:</strong> ${input.blockchain?.contractAddress ?? "N/A"}</li>
<li><strong>Explorer URL:</strong> ${input.blockchain?.explorerUrl ? `<a href="${input.blockchain.explorerUrl}" target="_blank" rel="noopener noreferrer">Open transaction</a>` : "N/A"}</li>
</ul>
<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>
${contractUrl ? `<p><a href="${contractUrl}">Open this contract in your dashboard</a></p>` : ""}
<p style="margin-top: 24px; font-size: 12px; color: #475569;">Keep this email for your records.</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>
`;
@@ -259,11 +332,14 @@ export class EmailService {
from,
to: input.to,
subject: `Contract analyzed: ${input.contractTitle}`,
text: textBody,
html: htmlBody,
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
const previewUrl = nodemailer.getTestMessageUrl(info);
const previewUrl =
(nodemailer.getTestMessageUrl(info) as string | false) || null;
if (previewUrl) {
console.log(`📨 Ethereal preview URL: ${previewUrl}`);
}
@@ -277,4 +353,160 @@ export class EmailService {
};
}
}
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",
};
}
}
}