Skip to content

Commit bc5fc36

Browse files
authored
Merge pull request #288 from programmierbar/mail-reply-to
Add a configurable Reply-To to all CMS mails
2 parents bb363dd + d415280 commit bc5fc36

6 files changed

Lines changed: 83 additions & 1 deletion

File tree

‎directus-cms/.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ EMAIL_FROM="noreply@programmier.bar"
115115
EMAIL_TRANSPORT="sendmail"
116116
EMAIL_SENDMAIL_NEW_LINE="unix"
117117

118+
# Reply-To for every mail Directus sends (set by the bundle's `mail-reply-to` hook), since
119+
# EMAIL_FROM cannot receive replies. Unset or empty: mails go out without Reply-To.
120+
# EMAIL_REPLY_TO="podcast@programmier.bar"
121+
118122
# For production SMTP (Gmail):
119123
# EMAIL_TRANSPORT="smtp"
120124
# EMAIL_SMTP_HOST="smtp.gmail.com"

‎directus-cms/extensions/directus-extension-programmierbar-bundle/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
"name": "process-guard",
3333
"source": "src/process-guard/index.ts"
3434
},
35+
{
36+
"type": "hook",
37+
"name": "mail-reply-to",
38+
"source": "src/mail-reply-to/index.ts"
39+
},
3540
{
3641
"type": "hook",
3742
"name": "deploy-website",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, jest, test } from '@jest/globals'
2+
import registerHook from '../index.ts'
3+
4+
// The extensions SDK ships as ESM and is not transformed under Jest's CJS mode,
5+
// so stub it. The real `defineHook` simply returns its callback.
6+
jest.mock('@directus/extensions-sdk', () => ({
7+
defineHook: (callback: unknown) => callback,
8+
}))
9+
10+
type Filter = (payload: any, meta: any, context: any) => any
11+
12+
function setup(env: Record<string, unknown>) {
13+
const filters = new Map<string, Filter>()
14+
registerHook(
15+
{ filter: (event: string, handler: Filter) => filters.set(event, handler) } as any,
16+
{ env, logger: { info: jest.fn() } } as any
17+
)
18+
return filters
19+
}
20+
21+
describe('mail-reply-to hook', () => {
22+
test('sets Reply-To from EMAIL_REPLY_TO', () => {
23+
const filter = setup({ EMAIL_REPLY_TO: ' replies@example.com ' }).get('email.send')!
24+
const message = { to: 'a@example.com', subject: 'Hi', html: '<p>Hi</p>' }
25+
26+
expect(filter(message, {}, {})).toEqual({ ...message, replyTo: 'replies@example.com' })
27+
// The original message is not mutated.
28+
expect(message).not.toHaveProperty('replyTo')
29+
})
30+
31+
test('keeps a Reply-To the caller set', () => {
32+
const filter = setup({ EMAIL_REPLY_TO: 'replies@example.com' }).get('email.send')!
33+
const message = { to: 'a@example.com', subject: 'Hi', replyTo: 'speaker@example.com' }
34+
35+
expect(filter(message, {}, {})).toBe(message)
36+
})
37+
38+
test.each([[{}], [{ EMAIL_REPLY_TO: '' }], [{ EMAIL_REPLY_TO: ' ' }]])(
39+
'registers no filter when EMAIL_REPLY_TO is unset or empty (%j)',
40+
(env) => {
41+
expect(setup(env).has('email.send')).toBe(false)
42+
}
43+
)
44+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { defineHook } from '@directus/extensions-sdk'
2+
3+
const HOOK_NAME = 'mail-reply-to'
4+
5+
/** Directus env var holding the address replies to CMS mails should go to. */
6+
export const EMAIL_REPLY_TO_ENV = 'EMAIL_REPLY_TO'
7+
8+
/**
9+
* Mails are sent from `EMAIL_FROM`, an address that cannot receive mail. Directus has no
10+
* setting for a default Reply-To, so this filter adds `EMAIL_REPLY_TO` to every mail that
11+
* goes through the MailService (`email.send` runs before each send): the bundle's own mails
12+
* (tickets, speaker portal, newsletter), Directus system mails (password reset, invites)
13+
* and Flows. A Reply-To set by the caller is kept. Unset or empty: no change.
14+
*/
15+
export default defineHook(({ filter }, { env, logger }) => {
16+
const replyTo = typeof env[EMAIL_REPLY_TO_ENV] === 'string' ? env[EMAIL_REPLY_TO_ENV].trim() : ''
17+
if (!replyTo) {
18+
logger.info(`${HOOK_NAME} hook: ${EMAIL_REPLY_TO_ENV} is not set, mails are sent without Reply-To.`)
19+
return
20+
}
21+
22+
filter('email.send', (message: any) => {
23+
if (!message || message.replyTo) {
24+
return message
25+
}
26+
return { ...message, replyTo }
27+
})
28+
})

‎docs/newsletter.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ would differ from what is sent. The only content filter is the renderer's publis
333333
`sendRawEmail`/`sendTemplatedEmail` in `src/shared/email-service.ts` (MailService passes it to
334334
nodemailer). No `mailto:` entry, because no mailbox processes unsubscribe mails. Plus `X-Mailin-custom: newsletter:test` / `X-Mailin-Tag: newsletter-test`
335335
for the Brevo webhook (see "Bounces and complaints"). From address: Directus
336-
`EMAIL_FROM`, as for all CMS mail.
336+
`EMAIL_FROM`, Reply-To: `EMAIL_REPLY_TO` (if set, `mail-reply-to` hook), as for all CMS mail.
337337
- **One-click on the website**: `nuxt-app/server/routes/newsletter/unsubscribe.post.ts` serves the same URL
338338
as the page. A POST with body `List-Unsubscribe=One-Click` (urlencoded or `multipart/form-data`, both
339339
allowed by RFC 8058) takes the token from the query, opts out and answers 200 (500 on a technical error) instead of the form's 303 redirect.

‎docs/tickets.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The flow consists of four main phases:
3131
| `STORAGE_LOCATIONS` | Comma-separated storage location IDs (first is used for invoice PDFs) | Yes |
3232
| `INVOICE_FILES_FOLDER` | Id of the `directus_files` folder invoice PDFs are uploaded into; the Public policy must not read it (see [Invoice File Privacy](#invoice-file-privacy)). Unset or invalid: invoices still upload, but into the public library (warning + Slack alert) | Yes |
3333
| Email transport config | Directus email settings (`EMAIL_TRANSPORT`, `EMAIL_SMTP_HOST`, etc.) | Yes |
34+
| `EMAIL_REPLY_TO` | Reply-To added to every mail Directus sends (`mail-reply-to` hook), so replies reach a real inbox instead of `EMAIL_FROM`. A Reply-To set by the sender is kept; unset or empty: no Reply-To | No |
3435
| `ACCOUNTING_INVOICE_EMAIL` | Accounting mailbox that receives a copy of every invoice document (see [Accounting copy](#accounting-copy)). Unset/empty: no copy is sent (Slack is alerted once per process start) | Production |
3536

3637
## External Services

0 commit comments

Comments
 (0)