Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions functions/src/api/other/renderPendingEditDecisionEmail.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
7 changes: 5 additions & 2 deletions functions/src/api/other/renderPendingEditDecisionEmail.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Speaker } from '../../types'
import { buildSpeakerEmailFooter } from './speakerEmailFooter'

const FIELD_LABELS: Record<string, string> = {
name: 'Name',
Expand Down Expand Up @@ -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),
}
}

Expand All @@ -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),
}
}
34 changes: 34 additions & 0 deletions functions/src/api/other/sendEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
14 changes: 14 additions & 0 deletions functions/src/api/other/sendEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' },
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions functions/src/api/other/speakerEmailFooter.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
19 changes: 19 additions & 0 deletions functions/src/api/other/speakerEmailFooter.ts
Original file line number Diff line number Diff line change
@@ -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}.`
8 changes: 7 additions & 1 deletion functions/src/api/routes/speakers/approvePendingEditPOST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion functions/src/api/routes/speakers/rejectPendingEditPOST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 }
)
}
Expand Down
37 changes: 34 additions & 3 deletions functions/src/api/routes/speakers/requestEditLinkPOST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading