EMail templates rework

This commit is contained in:
2026-05-02 22:03:19 +02:00
parent 11157b872c
commit 450e5ce23c

View File

@@ -11,6 +11,17 @@
* SMTP_PASS — App Password * SMTP_PASS — App Password
* SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>" * SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>"
* ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional) * ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional)
* SUPPORT_CONTACT_EMAIL — e.g. support@pieced.ch (optional)
* Customer-facing address for "have
* questions?" follow-ups in
* transactional emails. The from
* address itself (SMTP_USER) is
* typically a noreply mailbox, so we
* don't tell customers to "reply to
* this email" — instead we point them
* at this monitored address. If
* unset, the contact-prompt line is
* simply omitted from emails.
*/ */
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@@ -42,6 +53,12 @@ function getFrom(): string {
); );
} }
/** Returns the customer-facing support email address, or null if unset. */
function getSupportContactEmail(): string | null {
const v = process.env.SUPPORT_CONTACT_EMAIL?.trim();
return v && v.length > 0 ? v : null;
}
/** /**
* Escape HTML entities to prevent injection in HTML emails. * Escape HTML entities to prevent injection in HTML emails.
*/ */
@@ -125,6 +142,21 @@ export async function sendRejectionEmail(
</div>` </div>`
: ""; : "";
const supportEmail = getSupportContactEmail();
// The customer here is rejected pre-onboarding — they don't yet
// have a portal account, so we can't send them to /support.
// Instead point at the configured support address (if set).
// If unset (e.g. early pilot before a support inbox exists), we
// omit the follow-up line entirely rather than promise something
// that goes nowhere — telling the customer to "reply to this
// email" would be misleading because we send from a noreply box.
const contactLineText = supportEmail
? `If you have questions or would like to discuss this further, please contact us at ${supportEmail}.`
: "";
const contactLineHtml = supportEmail
? `<p>If you have questions or would like to discuss this further, please contact us at <a href="mailto:${escapeHtml(supportEmail)}" style="color: #3b82f6;">${escapeHtml(supportEmail)}</a>.</p>`
: "";
await getTransporter().sendMail({ await getTransporter().sendMail({
from: getFrom(), from: getFrom(),
to, to,
@@ -134,18 +166,20 @@ export async function sendRejectionEmail(
"", "",
`Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`, `Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`,
notesBlock, notesBlock,
"If you have questions or would like to discuss this further, please reply to this email.", contactLineText,
"", "",
"Best regards,", "Best regards,",
"PieCed IT", "PieCed IT",
].join("\n"), ]
.filter((s) => s !== "")
.join("\n"),
html: ` html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2> <h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
<p>Hello ${safeName},</p> <p>Hello ${safeName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p> <p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
${notesHtml} ${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p> ${contactLineHtml}
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" /> <hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p> <p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div> </div>
@@ -237,6 +271,15 @@ export async function sendResumeRejectionEmail(
</div>` </div>`
: ""; : "";
// The customer has portal access (their tenant exists, they
// just had a resume request rejected), so direct them to the
// support ticket system for follow-up. We never tell them to
// "reply to this email" because the from address is a noreply
// mailbox.
const contactLineText =
"If you have questions, open a support ticket at https://app.pieced.ch/support.";
const contactLineHtml = `<p>If you have questions, <a href="https://app.pieced.ch/support" style="color: #3b82f6;">open a support ticket</a>.</p>`;
await getTransporter().sendMail({ await getTransporter().sendMail({
from: getFrom(), from: getFrom(),
to, to,
@@ -248,7 +291,7 @@ export async function sendResumeRejectionEmail(
notesBlock, notesBlock,
"Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.", "Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.",
"", "",
"If you have questions, please reply to this email.", contactLineText,
"", "",
"Best regards,", "Best regards,",
"PieCed IT", "PieCed IT",
@@ -260,7 +303,7 @@ export async function sendResumeRejectionEmail(
<p>Thank you for your reactivation request for <strong>${safeCompany}</strong>. Unfortunately, we were unable to approve it at this time.</p> <p>Thank you for your reactivation request for <strong>${safeCompany}</strong>. Unfortunately, we were unable to approve it at this time.</p>
${notesHtml} ${notesHtml}
<p>Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.</p> <p>Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.</p>
<p>If you have questions, please reply to this email.</p> ${contactLineHtml}
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" /> <hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p> <p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div> </div>