diff --git a/packages/common/src/ErrorPage.ts b/packages/common/src/ErrorPage.ts new file mode 100644 index 00000000..d145461a --- /dev/null +++ b/packages/common/src/ErrorPage.ts @@ -0,0 +1,79 @@ +export type ErrorPageOptions = { + message?: string + stack?: string + code?: number + title?: string +} + +/** The `view()` global registered by `@arkstack/view`. */ +type ViewGlobal = { + view?: (name: string, data?: Record) => { renderSync(): string } +} + +/** + * Renders the HTML error page, using the `~arkstack/common.error` view when + * `@arkstack/view` is installed and a built-in document otherwise. + */ +export class ErrorPage { + static readonly titles: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + } + + static render({ message = 'An unexpected error occurred.', stack, title, code = 500 }: ErrorPageOptions = {}) { + const data = { code, title: this.titles[code] || title || 'Error', stack, message } + const view = (globalThis as unknown as ViewGlobal).view + + if (typeof view === 'function') { + return view('~arkstack/common.error', data).renderSync() + } + + return this.document(data) + } + + private static document({ code, title, stack, message }: Required> & { stack?: string }) { + return [ + '', + '', + '', + '', + '', + `${code} | ${this.escape(title)}`, + '', + '', + '', + '
', + `
${code}
`, + `
${this.escape(title)}
`, + `

${this.escape(message)}

`, + ...(stack ? [`
${this.escape(stack)}
`] : []), + '
', + '', + '', + ].join('\n') + } + + private static escape(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 8d5e0e28..2f66a10e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -6,6 +6,7 @@ export * from './Exceptions/Exception' export * from './Exceptions/AppException' export * from './Exceptions/RequestException' export * from './ErrorHandler' +export * from './ErrorPage' export * from './Publisher' export * from './Logger' export * from './Hook' diff --git a/packages/common/src/network.ts b/packages/common/src/network.ts index 13777508..166a21a2 100644 --- a/packages/common/src/network.ts +++ b/packages/common/src/network.ts @@ -1,6 +1,7 @@ import { abort, abortIf, assertFound, initializeGlobalContext } from './utils' import { config, env } from './system' +import { ErrorPage, type ErrorPageOptions } from './ErrorPage' import { Arkstack } from '@arkstack/contract' import { Hook } from '@arkstack/foundry' import { bindGracefulShutdown } from './lifecycle' @@ -45,41 +46,5 @@ export const bootWithDetectedPort = async ) => { renderSync(): string } -} - -export const renderError = ({ - message = 'An unexpected error occurred.', - stack, - title, - code = 500, -}: { - message?: string - stack?: string - code?: number - title?: string -}) => { - const titleMap: Record = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - 504: 'Gateway Timeout', - } - - title = titleMap[code] || title || 'Error' - - return (globalThis as unknown as ViewGlobal) - .view('~arkstack/common.error', { - code, - title, - stack, - message, - }) - .renderSync() -} +/** Renders the HTML error page, with or without `@arkstack/view` installed. */ +export const renderError = (options: ErrorPageOptions = {}) => ErrorPage.render(options) diff --git a/packages/common/tests/error-page.test.ts b/packages/common/tests/error-page.test.ts new file mode 100644 index 00000000..6c98f6b0 --- /dev/null +++ b/packages/common/tests/error-page.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'vitest' + +import { renderError } from '../src/network' + +describe('renderError', () => { + afterEach(() => { + delete (globalThis as Record).view + }) + + test('renders a built-in page when @arkstack/view is not installed', () => { + const html = renderError({ code: 404, message: '', stack: 'at ' }) + + expect(html).toContain('404 | Not Found') + expect(html).toContain('<missing>') + expect(html).toContain('
at <anonymous>
') + }) + + test('omits the stack block when no stack is given', () => { + expect(renderError({ code: 500 })).not.toContain('
')
+  })
+
+  test('renders through the view global when it is registered', () => {
+    ;(globalThis as Record).view = (name: string, data: Record) => ({
+      renderSync: () => `${name}:${data.code}:${data.title}`,
+    })
+
+    expect(renderError({ code: 403 })).toBe('~arkstack/common.error:403:Forbidden')
+  })
+})
diff --git a/packages/notifications/src/drivers/MailNotification.ts b/packages/notifications/src/drivers/MailNotification.ts
index 5fab056c..f278053e 100644
--- a/packages/notifications/src/drivers/MailNotification.ts
+++ b/packages/notifications/src/drivers/MailNotification.ts
@@ -22,7 +22,8 @@ import Mail, { Attachment } from 'nodemailer/lib/mailer'
 export class MailNotification extends NotificationContract {
   driver!: Transporter
   private options: MailDriverOptions = {}
-  private ViewName: string = '~arkstack/notifications.mail'
+  private static readonly defaultView = '~arkstack/notifications.mail'
+  private ViewName: string = MailNotification.defaultView
   private transport: NonNullable | Transport = 'smtp'
   private sesOptions?: Record
   private recipients?: MailRecipient
@@ -225,6 +226,35 @@ export class MailNotification extends NotificationContract {
     return this
   }
 
+  /**
+   * Render the html body from the html template, the configured view, or the
+   * built-in layout when `@arkstack/view` is not installed.
+   */
+  private async renderHtml(data: NotificationData) {
+    if (this.htmlTemplate) {
+      return interpolate(this.htmlTemplate, data)
+    }
+
+    if (typeof globalThis.view === 'function') {
+      return await globalThis.view(this.ViewName, data)
+    }
+
+    if (this.ViewName !== MailNotification.defaultView) {
+      throw new Error(`Mail view [${this.ViewName}] requires @arkstack/view to be installed.`)
+    }
+
+    return [
+      '',
+      '',
+      '',
+      `

${data.subject ?? ''}

`, + `
${data.message ?? ''}
`, + `

© ${new Date().getFullYear()} ${data.app_name ?? ''}

`, + '', + '', + ].join('\n') + } + /** * Prepare a notification to be sent. * @@ -284,9 +314,7 @@ export class MailNotification extends NotificationContract { ...templateData, message: textMessage, }), - html: this.htmlTemplate - ? interpolate(this.htmlTemplate, templateData) - : await globalThis.view(this.ViewName, templateData), + html: await this.renderHtml(templateData), } if (this.transport === 'ses') { diff --git a/packages/notifications/tests/notification.test.ts b/packages/notifications/tests/notification.test.ts index 20b86b50..edf7cd3e 100644 --- a/packages/notifications/tests/notification.test.ts +++ b/packages/notifications/tests/notification.test.ts @@ -258,6 +258,33 @@ describe('Notification', () => { expect(content).toContain('Hello Ada') }) + it('delivers mail with the built-in layout when @arkstack/view is not installed', async () => { + const view = globalThis.view + + delete (globalThis as Record).view + + try { + await Notification.mail({ from: 'noreply@arkstack.test', host: 'smtp.arkstack.test', port: 2525 }) + .recipient('ada@example.com') + .subject('Welcome, {name}') + .data({ app_name: 'Arkstack', name: 'Ada' }) + .send('Hello {name}') + + const content = String(mocks.sendMail.mock.calls[0][0].html) + + expect(content).toContain('

Welcome, Ada

') + expect(content).toContain('
Hello Ada
') + await expect( + Notification.mail({ from: 'noreply@arkstack.test', host: 'smtp.arkstack.test', port: 2525 }) + .recipient('ada@example.com') + .view('emails.welcome') + .send('Hello'), + ).rejects.toThrow('requires @arkstack/view') + } finally { + globalThis.view = view + } + }) + it('delivers mail to named address recipients', async () => { const recipients = [{ 'ada@example.com': 'Ada Lovelace' }, { 'grace@example.com': 'Grace Hopper' }] const nodemailerRecipients = [