diff --git a/src/backend/clients/email/EmailClient.ts b/src/backend/clients/email/EmailClient.ts index 6c747c99b..6548e0652 100644 --- a/src/backend/clients/email/EmailClient.ts +++ b/src/backend/clients/email/EmailClient.ts @@ -152,11 +152,16 @@ export class EmailClient extends PuterClient { // -- Public API: sending ------------------------------------------ - /** Render a template and send it to `to`. */ + /** + * Render a template and send it to `to`. `options.replyTo` sets the + * Reply-To header (e.g. so a recipient can respond to the originator of the + * message rather than the no-reply From address). + */ async send( to: string, template: T, values: Record = {}, + options: { replyTo?: string } = {}, ): Promise { const compiled = this.compiledTemplates[template]; if (!compiled) { @@ -168,6 +173,7 @@ export class EmailClient extends PuterClient { to, subject: compiled.subject(values), html: compiled.html(values), + ...(options.replyTo ? { replyTo: options.replyTo } : {}), }); } diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index 986235b78..fc135d391 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -83,9 +83,10 @@ The Puter Team html: `

Hi{{#if owner_username}} {{owner_username}}{{/if}},

-{{sender_username}} sent feedback about {{app_title}}: +{{sender_username}}{{#if sender_email}} ({{sender_email}}){{/if}} sent feedback about {{app_title}}:

{{{nl2br message}}}
+{{#if sender_email}}

Just reply to this email to respond to them directly.

{{/if}}

You're receiving this because user feedback is enabled for your app. To stop receiving these emails, turn off feedback for the app (e.g. diff --git a/src/backend/controllers/feedback/AppFeedbackController.test.ts b/src/backend/controllers/feedback/AppFeedbackController.test.ts index 87531e917..3e9d15872 100644 --- a/src/backend/controllers/feedback/AppFeedbackController.test.ts +++ b/src/backend/controllers/feedback/AppFeedbackController.test.ts @@ -456,12 +456,14 @@ describe('AppFeedbackService owner email', () => { .mockResolvedValue(undefined); }; - it('emails the confirmed owner and marks the row emailed', async () => { + it('emails the confirmed owner with the verified sender email + reply-to', async () => { const send = mockEmailReady(); const { userId: ownerId } = await makeUser(); await confirmOwnerEmail(ownerId); const app = await makeApp(ownerId, { feedbackEnabled: true }); const { actor, userId } = await makeUser(); + // A verified sender email is what gets shared and used as reply-to. + await confirmOwnerEmail(userId); const sender = (await server.stores.user.getById(userId))!; await submit(actor, { app: app.name, message: 'hello dev' }); @@ -474,9 +476,11 @@ describe('AppFeedbackService owner email', () => { expect.objectContaining({ owner_username: owner.username, sender_username: sender.username, + sender_email: sender.email, app_name: app.name, message: 'hello dev', }), + expect.objectContaining({ replyTo: sender.email }), ); const rows = (await server.clients.db.read( @@ -486,6 +490,22 @@ describe('AppFeedbackService owner email', () => { expect(Boolean(rows[0]?.email_sent)).toBe(true); }); + it('does not share an unverified sender email (no reply-to)', async () => { + const send = mockEmailReady(); + const { userId: ownerId } = await makeUser(); + await confirmOwnerEmail(ownerId); + const app = await makeApp(ownerId, { feedbackEnabled: true }); + // Sender's email is left unverified (makeUser does not confirm it). + const { actor } = await makeUser(); + + await submit(actor, { app: app.name, message: 'hello dev' }); + + expect(send).toHaveBeenCalledTimes(1); + const [, , values, options] = send.mock.calls[0]; + expect((values as Record).sender_email).toBeNull(); + expect((options as { replyTo?: string } | undefined)?.replyTo).toBeUndefined(); + }); + it('stores but does not email when the owner email is unconfirmed', async () => { const send = mockEmailReady(); const { userId: ownerId } = await makeUser(); diff --git a/src/backend/services/feedback/AppFeedbackService.ts b/src/backend/services/feedback/AppFeedbackService.ts index 8bb6f4653..c79698fa7 100644 --- a/src/backend/services/feedback/AppFeedbackService.ts +++ b/src/backend/services/feedback/AppFeedbackService.ts @@ -278,21 +278,31 @@ export class AppFeedbackService extends PuterService { } const sender = await this.stores.user.getById(senderUserId); + // Share the sender's email so the developer can respond — but only + // when it's verified. An unverified address can be anyone's (typed at + // signup, never proven), so using it as Reply-To would let a sender + // point the developer's reply at a stranger's inbox. Unverified + // senders still get their feedback delivered, just without a + // reply path. The dialog tells the user their email will be shared. + const senderEmail = + sender?.email && sender.email_confirmed ? sender.email : null; - await this.clients.email.send(owner.email, 'app-user-feedback', { - owner_username: owner.username, - // The sender's username is already visible to the app itself - // (puter.auth.getUser), so surfacing it here discloses nothing - // new — and the dialog tells the user it will be shared. The - // sender's email is never included. - sender_username: sender?.username ?? 'A Puter user', - // Collapse whitespace so a crafted title can't break the - // subject header or spoof extra lines in the body. - app_title: String(app.title ?? app.name).replace(/\s+/g, ' '), - app_name: String(app.name), - app_link: `${this.config.origin}/app/${encodeURIComponent(String(app.name))}`, - message, - }); + await this.clients.email.send( + owner.email, + 'app-user-feedback', + { + owner_username: owner.username, + sender_username: sender?.username ?? 'A Puter user', + sender_email: senderEmail, + // Collapse whitespace so a crafted title can't break the + // subject header or spoof extra lines in the body. + app_title: String(app.title ?? app.name).replace(/\s+/g, ' '), + app_name: String(app.name), + app_link: `${this.config.origin}/app/${encodeURIComponent(String(app.name))}`, + message, + }, + senderEmail ? { replyTo: senderEmail } : {}, + ); await this.stores.appFeedback.markEmailSent(feedbackId); } diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 13f5bc225..32f390400 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -1424,10 +1424,6 @@ const ipc_listener = async (event, handled) => { sent = await UIWindowAppFeedback({ app: app_uuid || app_name, source: 'app', - window_options: { - parent_uuid: event.data.appInstanceID, - disable_parent_window: true, - }, }); } catch ( e ) { console.error('IPC showFeedbackDialog failed', e); diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index ad4f1f5dc..7fce54d18 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -4611,16 +4611,19 @@ function attach_dashboard_app_drawer (el_window, options) { const feedback_enabled = options.feedback_enabled === true || options.feedback_enabled === 1; const feedback_label = i18n('app_feedback_title'); + // A message/comment glyph (bubble with text lines) — clearer at this size + // than a bare speech bubble, which reads as a magnifier. const feedback_btn = feedback_enabled ? ` ` : ''; // The toggle comes FIRST in the DOM so Tab reaches it before the // controls' buttons; both layers are absolutely positioned (see - // dashboard.css), so DOM order doesn't affect the visuals. + // dashboard.css), so DOM order doesn't affect the visuals. `has-feedback` + // widens the surface so the extra control doesn't clip the close button. const $drawer = $(` -