Przeglądaj źródła

Set Reply-To on support request mails so replying from the inbox reaches the person who asked (#347)

Bernt Christian Egeland 3 tygodni temu
rodzic
commit
d08e9f8c05

+ 20 - 0
src/__tests__/features/support/support-request.test.ts

@@ -21,6 +21,7 @@ import {
   exceedsRequestLimit,
   exceedsRequestLimit,
   sanitizeFilename,
   sanitizeFilename,
   validateSupportRequest,
   validateSupportRequest,
+  supportReplyToAddress,
 } from '@/features/support/Lib/supportRequest'
 } from '@/features/support/Lib/supportRequest'
 
 
 const attachment = (
 const attachment = (
@@ -299,3 +300,22 @@ describe('buildSupportEmailHtml', () => {
     expect(html).toContain('kari@example.com')
     expect(html).toContain('kari@example.com')
   })
   })
 })
 })
+
+describe('supportReplyToAddress', () => {
+  it('pairs the name with the address so the inbox shows who is asking', () => {
+    expect(supportReplyToAddress('Kari Nordmann', 'kari@example.com')).toBe(
+      '"Kari Nordmann" <kari@example.com>'
+    )
+  })
+
+  it('falls back to the bare address when there is no usable name', () => {
+    expect(supportReplyToAddress(null, 'kari@example.com')).toBe('kari@example.com')
+    expect(supportReplyToAddress('   ', 'kari@example.com')).toBe('kari@example.com')
+  })
+
+  it('strips characters that would break the header', () => {
+    expect(supportReplyToAddress('Evil" <boss@example.com>\r\nBcc: x', 'kari@example.com')).toBe(
+      '"Evil boss@example.com Bcc: x" <kari@example.com>'
+    )
+  })
+})

+ 4 - 0
src/app/api/protected/support/route.ts

@@ -18,6 +18,7 @@ import {
   MAX_TOTAL_ATTACHMENT_BYTES,
   MAX_TOTAL_ATTACHMENT_BYTES,
   sanitizeFilename,
   sanitizeFilename,
   validateSupportRequest,
   validateSupportRequest,
+  supportReplyToAddress,
 } from '@/features/support/Lib/supportRequest'
 } from '@/features/support/Lib/supportRequest'
 
 
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
@@ -119,6 +120,9 @@ export async function POST(request: NextRequest) {
     await sendMail({
     await sendMail({
       from,
       from,
       to,
       to,
+      // Hitting reply in the inbox should address the person who asked, not
+      // the platform sender the mail went out from.
+      replyTo: supportReplyToAddress(user.name, user.email),
       subject: `[Support] ${validation.subject}`,
       subject: `[Support] ${validation.subject}`,
       html,
       html,
       attachments,
       attachments,

+ 18 - 0
src/features/support/Lib/supportRequest.ts

@@ -152,6 +152,24 @@ export function sanitizeFilename(filename: string, fallback: string): string {
   return cleaned.slice(0, 120)
   return cleaned.slice(0, 120)
 }
 }
 
 
+/**
+ * The Reply-To address for a support mail, so answering from the inbox goes to
+ * the person who asked rather than to the platform sender.
+ *
+ * The display name is whatever the user typed into their profile, and it is
+ * going into a mail header. Quotes, angle brackets and line breaks are stripped
+ * rather than escaped: a name is only a courtesy here, the address is what
+ * matters, and a mangled name is better than a header the mailer refuses.
+ */
+export function supportReplyToAddress(name: string | null, email: string): string {
+  const cleaned = (name ?? '')
+    .replace(/[\r\n"<>,;\\]+/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+  if (!cleaned) return email
+  return `"${cleaned}" <${email}>`
+}
+
 export interface SupportContext {
 export interface SupportContext {
   organizationName: string
   organizationName: string
   organizationId: string
   organizationId: string

+ 13 - 0
src/lib/email.ts

@@ -21,6 +21,12 @@ export type EmailProvider = 'smtp' | 'resend' | 'postmark' | 'mailgun' | 'sendgr
 export interface SendMailOptions {
 export interface SendMailOptions {
   from: string
   from: string
   to: string
   to: string
+  /**
+   * Where a reply should go when it is not the sender. A support request goes
+   * out from the platform address, so without this the administrator's reply
+   * button addresses the platform itself rather than the person asking.
+   */
+  replyTo?: string
   subject: string
   subject: string
   html: string
   html: string
   attachments?: {
   attachments?: {
@@ -195,6 +201,7 @@ async function sendViaSmtpWithSettings(
   await transporter.sendMail({
   await transporter.sendMail({
     from: options.from,
     from: options.from,
     to: options.to,
     to: options.to,
+    replyTo: options.replyTo,
     subject: options.subject,
     subject: options.subject,
     html: options.html,
     html: options.html,
     attachments: options.attachments?.map((a) => ({
     attachments: options.attachments?.map((a) => ({
@@ -247,6 +254,7 @@ async function sendViaResendWithSettings(
   await resend.emails.send({
   await resend.emails.send({
     from: options.from,
     from: options.from,
     to: options.to,
     to: options.to,
+    replyTo: options.replyTo,
     subject: options.subject,
     subject: options.subject,
     html: options.html,
     html: options.html,
     attachments: options.attachments?.map((a) => ({
     attachments: options.attachments?.map((a) => ({
@@ -279,6 +287,7 @@ async function sendViaPostmarkWithSettings(
     await client.sendEmail({
     await client.sendEmail({
       From: options.from,
       From: options.from,
       To: options.to,
       To: options.to,
+      ReplyTo: options.replyTo,
       Subject: options.subject,
       Subject: options.subject,
       HtmlBody: options.html,
       HtmlBody: options.html,
       Attachments: options.attachments.map((a) => ({
       Attachments: options.attachments.map((a) => ({
@@ -292,6 +301,7 @@ async function sendViaPostmarkWithSettings(
     await client.sendEmail({
     await client.sendEmail({
       From: options.from,
       From: options.from,
       To: options.to,
       To: options.to,
+      ReplyTo: options.replyTo,
       Subject: options.subject,
       Subject: options.subject,
       HtmlBody: options.html,
       HtmlBody: options.html,
     })
     })
@@ -328,6 +338,7 @@ async function sendViaMailgunWithSettings(
   await mg.messages.create(domain, {
   await mg.messages.create(domain, {
     from: options.from,
     from: options.from,
     to: [options.to],
     to: [options.to],
+    ...(options.replyTo && { 'h:Reply-To': options.replyTo }),
     subject: options.subject,
     subject: options.subject,
     html: options.html,
     html: options.html,
     ...(options.attachments?.length && {
     ...(options.attachments?.length && {
@@ -370,6 +381,7 @@ async function sendViaSendGridWithSettings(
   const msg: MailDataRequired = {
   const msg: MailDataRequired = {
     from: options.from,
     from: options.from,
     to: options.to,
     to: options.to,
+    replyTo: options.replyTo,
     subject: options.subject,
     subject: options.subject,
     html: options.html,
     html: options.html,
   }
   }
@@ -410,6 +422,7 @@ async function sendViaSesWithSettings(
   const info = await transporter.sendMail({
   const info = await transporter.sendMail({
     from: options.from,
     from: options.from,
     to: options.to,
     to: options.to,
+    replyTo: options.replyTo,
     subject: options.subject,
     subject: options.subject,
     html: options.html,
     html: options.html,
     attachments: options.attachments?.map((a) => ({
     attachments: options.attachments?.map((a) => ({