diff --git a/functions/src/api/other/renderPendingEditDecisionEmail.test.ts b/functions/src/api/other/renderPendingEditDecisionEmail.test.ts new file mode 100644 index 0000000..1394bca --- /dev/null +++ b/functions/src/api/other/renderPendingEditDecisionEmail.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest' +import { renderApprovedEmail, renderRejectedEmail } from './renderPendingEditDecisionEmail' + +const eventName = 'My Event' + +describe('renderApprovedEmail', () => { + test('lists the applied fields and identifies OpenPlanner as the sender', () => { + const { subject, text } = renderApprovedEmail('Jane', eventName, { + name: 'Jane Doe', + jobTitle: 'Lead Engineer', + }) + expect(subject).toMatch(/approved/i) + expect(text).toMatch(/Jane/) + expect(text).toMatch(/Lead Engineer/) + // Footer must always carry the OpenPlanner attribution + reply-to + // contact so the speaker knows who is writing regardless of the + // From header. + expect(text).toContain('OpenPlanner') + expect(text).toContain('contact@email.openplanner.fr') + expect(text).toContain(eventName) + }) +}) + +describe('renderRejectedEmail', () => { + test('mentions the rejection note and identifies OpenPlanner as the sender', () => { + const { subject, text } = renderRejectedEmail('Jane', eventName, { bio: 'lorem' }, 'Bio too long') + expect(subject).toMatch(/not applied|rejected/i) + expect(text).toMatch(/Bio too long/) + expect(text).toContain('OpenPlanner') + expect(text).toContain('contact@email.openplanner.fr') + }) + + test('omits the reviewer-note section when none provided', () => { + const { text } = renderRejectedEmail('Jane', eventName, { bio: 'lorem' }) + expect(text).not.toMatch(/Reviewer note/i) + expect(text).toContain('contact@email.openplanner.fr') + }) +}) diff --git a/functions/src/api/other/renderPendingEditDecisionEmail.ts b/functions/src/api/other/renderPendingEditDecisionEmail.ts index e0f49e5..928e28f 100644 --- a/functions/src/api/other/renderPendingEditDecisionEmail.ts +++ b/functions/src/api/other/renderPendingEditDecisionEmail.ts @@ -1,4 +1,5 @@ import { Speaker } from '../../types' +import { buildSpeakerEmailFooter } from './speakerEmailFooter' const FIELD_LABELS: Record = { name: 'Name', @@ -55,7 +56,8 @@ export const renderApprovedEmail = ( `Hello ${speakerName},\n\n` + `Your profile changes for "${eventName}" have been approved by an administrator and will be public soon.\n\n` + `Changes applied:\n${changes}\n\n` + - `If you did not request these changes, please contact the event organisers.`, + `If you did not request these changes, please contact the event organisers.\n\n` + + buildSpeakerEmailFooter(eventName), } } @@ -73,6 +75,7 @@ export const renderRejectedEmail = ( `Hello ${speakerName},\n\n` + `Your recent profile changes for "${eventName}" were not applied by the administrators.\n\n` + `Changes you proposed:\n${changes}${noteSection}\n\n` + - `You can request a fresh edit link from the speaker self-edit page if you want to retry.`, + `You can request a fresh edit link from the speaker self-edit page if you want to retry.\n\n` + + buildSpeakerEmailFooter(eventName), } } diff --git a/functions/src/api/other/sendEmail.test.ts b/functions/src/api/other/sendEmail.test.ts index 4c78837..16f45f5 100644 --- a/functions/src/api/other/sendEmail.test.ts +++ b/functions/src/api/other/sendEmail.test.ts @@ -132,6 +132,40 @@ describe('sendEmail', () => { }) }) + test('forwards replyTo to nodemailer and persists it on the audit row', async () => { + const setSpy = vi.fn(() => Promise.resolve()) + const addSpy = vi.fn() + nodemailerMocks.sendMail.mockResolvedValueOnce({ messageId: 'mid-rt', response: '250 OK' }) + + await sendEmail(makeFirebaseApp(setSpy, addSpy), { + to: 'jane@example.com', + subject: 'Hello', + text: 'body', + replyTo: 'contact@email.openplanner.fr', + }) + const sendArg = nodemailerMocks.sendMail.mock.calls[0][0] + expect(sendArg.replyTo).toBe('contact@email.openplanner.fr') + + const pendingDoc = addSpy.mock.calls[0][0] + expect(pendingDoc.replyTo).toBe('contact@email.openplanner.fr') + }) + + test('omits replyTo header when caller did not pass one and stamps null on the audit row', async () => { + const setSpy = vi.fn(() => Promise.resolve()) + const addSpy = vi.fn() + nodemailerMocks.sendMail.mockResolvedValueOnce({ messageId: 'mid-no-rt', response: '250 OK' }) + + await sendEmail(makeFirebaseApp(setSpy, addSpy), { + to: 'jane@example.com', + subject: 'Hello', + text: 'body', + }) + const sendArg = nodemailerMocks.sendMail.mock.calls[0][0] + expect(sendArg.replyTo).toBeUndefined() + const pendingDoc = addSpy.mock.calls[0][0] + expect(pendingDoc.replyTo).toBeNull() + }) + test('uses STARTTLS (secure=false) with requireTLS on port 587', async () => { __resetEmailTransporterForTests() process.env.MAILGUN_SMTP_PORT = '587' diff --git a/functions/src/api/other/sendEmail.ts b/functions/src/api/other/sendEmail.ts index 1f1182d..8fe2c91 100644 --- a/functions/src/api/other/sendEmail.ts +++ b/functions/src/api/other/sendEmail.ts @@ -9,6 +9,13 @@ export interface EmailMessage { subject: string text: string html?: string + /** + * Optional Reply-To header. Callers that want replies routed to a + * different mailbox than MAIL_FROM (e.g. the OpenPlanner contact inbox + * for speaker-facing mail while keeping a per-event MAIL_FROM) should + * pass the address here. Persisted on the audit row. + */ + replyTo?: string } // Convert a plain-text email body into safe HTML. Escapes every character @@ -122,6 +129,7 @@ export const sendEmail = async ( const docRef = await db.collection(collection).add({ to: message.to, from, + replyTo: message.replyTo ?? null, message: { subject, text: message.text, html }, createdAt: FieldValue.serverTimestamp(), delivery: { state: 'PENDING' }, @@ -148,6 +156,12 @@ export const sendEmail = async ( const info = await transporter.sendMail({ from, to: message.to, + // nodemailer accepts `replyTo` and emits a real Reply-To + // header so a recipient hitting Reply lands in the inbox we + // specify instead of MAIL_FROM. Falls back to undefined when + // the caller did not set one, matching nodemailer's + // "no header" default. + replyTo: message.replyTo, subject, text: message.text, html, diff --git a/functions/src/api/other/speakerEmailFooter.test.ts b/functions/src/api/other/speakerEmailFooter.test.ts new file mode 100644 index 0000000..b1c1837 --- /dev/null +++ b/functions/src/api/other/speakerEmailFooter.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' +import { OPENPLANNER_CONTACT_EMAIL, buildSpeakerEmailFooter } from './speakerEmailFooter' + +describe('speakerEmailFooter', () => { + test('OPENPLANNER_CONTACT_EMAIL is the canonical address', () => { + expect(OPENPLANNER_CONTACT_EMAIL).toBe('contact@email.openplanner.fr') + }) + + test('buildSpeakerEmailFooter interpolates the event name and embeds the contact', () => { + const out = buildSpeakerEmailFooter('My Conf') + expect(out).toContain('OpenPlanner') + expect(out).toContain('My Conf') + expect(out).toContain(OPENPLANNER_CONTACT_EMAIL) + // Leading separator line — kept for visual distinction in plain + // text clients. + expect(out.startsWith('--\n')).toBe(true) + }) +}) diff --git a/functions/src/api/other/speakerEmailFooter.ts b/functions/src/api/other/speakerEmailFooter.ts new file mode 100644 index 0000000..daed6dd --- /dev/null +++ b/functions/src/api/other/speakerEmailFooter.ts @@ -0,0 +1,19 @@ +// Single source of truth for the OpenPlanner attribution shown to +// speakers in every self-edit email (magic-link request, approval +// notification, rejection notification). Centralising the constant + +// the footer builder here prevents the wording from drifting across +// templates the next time the contact address or the copy changes. + +export const OPENPLANNER_CONTACT_EMAIL = 'contact@email.openplanner.fr' + +/** + * Plain-text attribution footer appended to every speaker-facing email + * the OpenPlanner backend sends. Per-event MAIL_FROM domains mean the + * From header alone is not a stable identifier for the speaker — this + * line tells them which platform is writing AND gives them a real + * inbox they can reply to (the SMTP Reply-To header is also set + * server-side; see sendEmail). + */ +export const buildSpeakerEmailFooter = (eventName: string): string => + `--\nThis email was sent by OpenPlanner on behalf of "${eventName}". ` + + `For questions, contact ${OPENPLANNER_CONTACT_EMAIL}.` diff --git a/functions/src/api/routes/speakers/approvePendingEditPOST.ts b/functions/src/api/routes/speakers/approvePendingEditPOST.ts index 2d11a60..dddc122 100644 --- a/functions/src/api/routes/speakers/approvePendingEditPOST.ts +++ b/functions/src/api/routes/speakers/approvePendingEditPOST.ts @@ -5,6 +5,7 @@ import { SpeakerDao } from '../../dao/speakerDao' import { EventDao } from '../../dao/eventDao' import { Speaker } from '../../../types' import { sendEmail } from '../../other/sendEmail' +import { OPENPLANNER_CONTACT_EMAIL } from '../../other/speakerEmailFooter' import { renderApprovedEmail } from '../../other/renderPendingEditDecisionEmail' const TypeBoxApproveBody = Type.Object( @@ -100,7 +101,12 @@ export const approvePendingEditRouteHandler = (fastify: FastifyInstance) => { ) await sendEmail( fastify.firebase, - { to: speakerBefore.email, subject: email.subject, text: email.text }, + { + to: speakerBefore.email, + subject: email.subject, + text: email.text, + replyTo: OPENPLANNER_CONTACT_EMAIL, + }, { eventId, speakerId: pending.speakerId, type: 'speaker-edit-approved', requestId } ) } catch (err) { diff --git a/functions/src/api/routes/speakers/rejectPendingEditPOST.ts b/functions/src/api/routes/speakers/rejectPendingEditPOST.ts index a4f6fc7..19be912 100644 --- a/functions/src/api/routes/speakers/rejectPendingEditPOST.ts +++ b/functions/src/api/routes/speakers/rejectPendingEditPOST.ts @@ -5,6 +5,7 @@ import { SpeakerDao } from '../../dao/speakerDao' import { EventDao } from '../../dao/eventDao' import { deletePendingPhotoFromUrl } from '../../other/deletePendingPhoto' import { sendEmail } from '../../other/sendEmail' +import { OPENPLANNER_CONTACT_EMAIL } from '../../other/speakerEmailFooter' import { renderRejectedEmail } from '../../other/renderPendingEditDecisionEmail' import { Speaker } from '../../../types' @@ -102,7 +103,12 @@ export const rejectPendingEditRouteHandler = (fastify: FastifyInstance) => { ) await sendEmail( fastify.firebase, - { to: speakerBefore.email, subject: email.subject, text: email.text }, + { + to: speakerBefore.email, + subject: email.subject, + text: email.text, + replyTo: OPENPLANNER_CONTACT_EMAIL, + }, { eventId, speakerId: pending.speakerId, type: 'speaker-edit-rejected', requestId } ) } diff --git a/functions/src/api/routes/speakers/requestEditLinkPOST.ts b/functions/src/api/routes/speakers/requestEditLinkPOST.ts index 29fda4d..f75e2fa 100644 --- a/functions/src/api/routes/speakers/requestEditLinkPOST.ts +++ b/functions/src/api/routes/speakers/requestEditLinkPOST.ts @@ -6,6 +6,7 @@ import { SpeakerEditTokenDao } from '../../dao/speakerEditTokenDao' import { SpeakerEditRateLimitDao } from '../../dao/speakerEditRateLimitDao' import { verifyCaptchaToken } from '../../other/captchaVerify' import { sendEmail } from '../../other/sendEmail' +import { OPENPLANNER_CONTACT_EMAIL, buildSpeakerEmailFooter } from '../../other/speakerEmailFooter' const TypeBoxRequestEditLink = Type.Object( { @@ -41,16 +42,37 @@ export const requestEditLinkPOSTSchema = { }, } +// Localised footer for the FR magic-link mail. Mirrors the EN footer +// from speakerEmailFooter.ts in shape, but keeping the wording on the +// route file lets us drop the per-language branch entirely if we ever +// merge templates. The shared OPENPLANNER_CONTACT_EMAIL constant is +// imported so the address only lives in one place. +const buildFrenchFooter = (eventName: string): string => + `--\nCet email est envoyé par OpenPlanner pour le compte de "${eventName}". ` + + `Pour toute question, contactez ${OPENPLANNER_CONTACT_EMAIL}.` + const renderEmail = (speakerName: string, eventName: string, link: string, lang: 'fr' | 'en') => { if (lang === 'fr') { return { subject: `Modifier votre profil — ${eventName}`, - text: `Bonjour ${speakerName},\n\nVous avez demandé un lien pour modifier votre profil public pour l'événement "${eventName}".\n\nCliquez ici (valable 7 jours) :\n${link}\n\nVos modifications seront vérifiées par un administrateur avant d'être publiées.\n\nSi vous n'êtes pas à l'origine de cette demande, ignorez cet email.`, + text: + `Bonjour ${speakerName},\n\n` + + `Vous avez demandé un lien pour modifier votre profil public pour l'événement "${eventName}".\n\n` + + `Cliquez ici (valable 7 jours) :\n${link}\n\n` + + `Vos modifications seront vérifiées par un administrateur avant d'être publiées.\n\n` + + `Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.\n\n` + + buildFrenchFooter(eventName), } } return { subject: `Edit your profile — ${eventName}`, - text: `Hello ${speakerName},\n\nYou requested a link to edit your public profile for "${eventName}".\n\nClick here (valid 7 days):\n${link}\n\nYour changes will be reviewed by an administrator before going live.\n\nIf you did not request this, ignore this email.`, + text: + `Hello ${speakerName},\n\n` + + `You requested a link to edit your public profile for "${eventName}".\n\n` + + `Click here (valid 7 days):\n${link}\n\n` + + `Your changes will be reviewed by an administrator before going live.\n\n` + + `If you did not request this, ignore this email.\n\n` + + buildSpeakerEmailFooter(eventName), } } @@ -130,7 +152,16 @@ export const requestEditLinkRouteHandler = (fastify: FastifyInstance) => { try { await sendEmail( fastify.firebase, - { to: matching.email as string, subject: email_.subject, text: email_.text }, + { + to: matching.email as string, + subject: email_.subject, + text: email_.text, + // Set the SMTP Reply-To header so a speaker hitting + // Reply lands in the OpenPlanner contact inbox rather + // than the per-event MAIL_FROM (which is often a + // no-reply alias). + replyTo: OPENPLANNER_CONTACT_EMAIL, + }, { eventId, speakerId: matching.id, type: 'speaker-edit-link' } ) } catch (err) {