@mantiq/mail 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Responsive email base template.
3
+ * - 600px max-width centered table layout (email-safe)
4
+ * - Inline CSS (email clients strip <style> tags)
5
+ * - Dark mode via @media (prefers-color-scheme: dark)
6
+ * - Outlook-compatible button (VML fallback not included — uses padding trick)
7
+ */
8
+
9
+ export interface ThemeOptions {
10
+ appName?: string
11
+ logoUrl?: string
12
+ }
13
+
14
+ const COLORS = {
15
+ bg: '#ffffff',
16
+ bgDark: '#1a1a1a',
17
+ surface: '#f9fafb',
18
+ surfaceDark: '#262626',
19
+ text: '#1a1a1a',
20
+ textDark: '#e5e5e5',
21
+ muted: '#6b7280',
22
+ mutedDark: '#9ca3af',
23
+ accent: '#10b981',
24
+ accentDark: '#34d399',
25
+ border: '#e5e7eb',
26
+ borderDark: '#374151',
27
+ panelBg: '#f3f4f6',
28
+ panelBgDark: '#1f2937',
29
+ } as const
30
+
31
+ const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
32
+ const MONO = "'SF Mono', ui-monospace, Menlo, Consolas, monospace"
33
+
34
+ export function wrapInLayout(bodyHtml: string, options: ThemeOptions = {}): string {
35
+ const appName = options.appName ?? 'MantiqJS'
36
+
37
+ return `<!DOCTYPE html>
38
+ <html lang="en" xmlns="http://www.w3.org/1999/xhtml">
39
+ <head>
40
+ <meta charset="UTF-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
42
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
43
+ <meta name="color-scheme" content="light dark">
44
+ <meta name="supported-color-schemes" content="light dark">
45
+ <title>${escHtml(appName)}</title>
46
+ <style>
47
+ @media (prefers-color-scheme: dark) {
48
+ .email-body { background-color: ${COLORS.bgDark} !important; }
49
+ .email-container { background-color: ${COLORS.bgDark} !important; }
50
+ .email-content { background-color: ${COLORS.bgDark} !important; color: ${COLORS.textDark} !important; }
51
+ .email-header { border-bottom-color: ${COLORS.borderDark} !important; }
52
+ .email-footer { border-top-color: ${COLORS.borderDark} !important; color: ${COLORS.mutedDark} !important; }
53
+ .email-text { color: ${COLORS.textDark} !important; }
54
+ .email-muted { color: ${COLORS.mutedDark} !important; }
55
+ .email-panel { background-color: ${COLORS.panelBgDark} !important; border-color: ${COLORS.borderDark} !important; }
56
+ .email-hr { border-color: ${COLORS.borderDark} !important; }
57
+ .email-code { background-color: ${COLORS.surfaceDark} !important; color: ${COLORS.accentDark} !important; }
58
+ .email-link { color: ${COLORS.accentDark} !important; }
59
+ .email-th { border-bottom-color: ${COLORS.borderDark} !important; color: ${COLORS.mutedDark} !important; }
60
+ .email-td { border-bottom-color: ${COLORS.borderDark} !important; color: ${COLORS.textDark} !important; }
61
+ .email-blockquote { border-left-color: ${COLORS.borderDark} !important; color: ${COLORS.mutedDark} !important; }
62
+ }
63
+ @media only screen and (max-width: 620px) {
64
+ .email-container { width: 100% !important; }
65
+ .email-content { padding: 24px 20px !important; }
66
+ }
67
+ </style>
68
+ </head>
69
+ <body class="email-body" style="margin:0;padding:0;background-color:${COLORS.bg};font-family:${FONT};-webkit-font-smoothing:antialiased;">
70
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${COLORS.bg};">
71
+ <tr>
72
+ <td align="center" style="padding:32px 16px;">
73
+ <table role="presentation" class="email-container" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;background-color:${COLORS.bg};">
74
+ <!-- Header -->
75
+ <tr>
76
+ <td class="email-header" style="padding:20px 32px;border-bottom:1px solid ${COLORS.border};">
77
+ <span style="font-size:15px;font-weight:600;color:${COLORS.text};letter-spacing:-0.02em;">${escHtml(appName)}</span>
78
+ </td>
79
+ </tr>
80
+ <!-- Content -->
81
+ <tr>
82
+ <td class="email-content" style="padding:32px;color:${COLORS.text};font-size:15px;line-height:1.65;">
83
+ ${bodyHtml}
84
+ </td>
85
+ </tr>
86
+ <!-- Footer -->
87
+ <tr>
88
+ <td class="email-footer" style="padding:20px 32px;border-top:1px solid ${COLORS.border};font-size:12px;color:${COLORS.muted};line-height:1.6;">
89
+ &copy; ${new Date().getFullYear()} ${escHtml(appName)}
90
+ </td>
91
+ </tr>
92
+ </table>
93
+ </td>
94
+ </tr>
95
+ </table>
96
+ </body>
97
+ </html>`
98
+ }
99
+
100
+ // ── Component renderers ─────────────────────────────────────────────────────
101
+
102
+ export function renderButton(url: string, text: string): string {
103
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
104
+ <tr>
105
+ <td style="background-color:${COLORS.accent};border-radius:6px;">
106
+ <a href="${escHtml(url)}" target="_blank" class="email-link" style="display:inline-block;padding:12px 24px;font-size:14px;font-weight:600;color:#ffffff !important;text-decoration:none;border-radius:6px;">${escHtml(text)}</a>
107
+ </td>
108
+ </tr>
109
+ </table>`
110
+ }
111
+
112
+ export function renderPanel(content: string): string {
113
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
114
+ <tr>
115
+ <td class="email-panel" style="padding:16px 20px;background-color:${COLORS.panelBg};border:1px solid ${COLORS.border};border-radius:6px;font-size:14px;line-height:1.6;color:${COLORS.text};">
116
+ ${content}
117
+ </td>
118
+ </tr>
119
+ </table>`
120
+ }
121
+
122
+ export function renderTable(headers: string[], rows: string[][]): string {
123
+ const ths = headers.map(h =>
124
+ `<th class="email-th" style="text-align:left;padding:8px 12px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:${COLORS.muted};border-bottom:2px solid ${COLORS.border};">${escHtml(h)}</th>`
125
+ ).join('')
126
+
127
+ const trs = rows.map(row =>
128
+ `<tr>${row.map(cell =>
129
+ `<td class="email-td" style="padding:10px 12px;font-size:14px;color:${COLORS.text};border-bottom:1px solid ${COLORS.border};">${escHtml(cell)}</td>`
130
+ ).join('')}</tr>`
131
+ ).join('')
132
+
133
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;border-collapse:collapse;">
134
+ <thead><tr>${ths}</tr></thead>
135
+ <tbody>${trs}</tbody>
136
+ </table>`
137
+ }
138
+
139
+ function escHtml(s: string): string {
140
+ return s
141
+ .replace(/&/g, '&amp;')
142
+ .replace(/</g, '&lt;')
143
+ .replace(/>/g, '&gt;')
144
+ .replace(/"/g, '&quot;')
145
+ }
@@ -0,0 +1,87 @@
1
+ import type { Mailable } from '../Mailable.ts'
2
+ import type { MailTransport } from '../contracts/Transport.ts'
3
+ import type { Message } from '../Message.ts'
4
+
5
+ /**
6
+ * In-memory mail fake for testing.
7
+ *
8
+ * @example
9
+ * const fake = new MailFake()
10
+ * // ... run code that sends mail ...
11
+ * fake.assertSent(WelcomeEmail)
12
+ * fake.assertSent(WelcomeEmail, 1)
13
+ * fake.assertNotSent(InvoiceEmail)
14
+ */
15
+ export class MailFake implements MailTransport {
16
+ private _sent: Message[] = []
17
+ private _sentMailables: Mailable[] = []
18
+ private _queued: Mailable[] = []
19
+
20
+ async send(message: Message): Promise<{ id: string }> {
21
+ this._sent.push(message)
22
+ return { id: crypto.randomUUID() }
23
+ }
24
+
25
+ /** Record a mailable as sent (called by test harness) */
26
+ recordSent(mailable: Mailable): void {
27
+ this._sentMailables.push(mailable)
28
+ }
29
+
30
+ /** Record a mailable as queued */
31
+ recordQueued(mailable: Mailable): void {
32
+ this._queued.push(mailable)
33
+ }
34
+
35
+ // ── Assertions ──────────────────────────────────────────────────────────
36
+
37
+ assertSent(mailableClass: new (...args: any[]) => Mailable, count?: number): void {
38
+ const matches = this._sentMailables.filter(m => m instanceof mailableClass)
39
+ if (matches.length === 0) {
40
+ throw new Error(`Expected [${mailableClass.name}] to be sent, but it was not.`)
41
+ }
42
+ if (count !== undefined && matches.length !== count) {
43
+ throw new Error(`Expected [${mailableClass.name}] to be sent ${count} time(s), but was sent ${matches.length} time(s).`)
44
+ }
45
+ }
46
+
47
+ assertNotSent(mailableClass: new (...args: any[]) => Mailable): void {
48
+ const matches = this._sentMailables.filter(m => m instanceof mailableClass)
49
+ if (matches.length > 0) {
50
+ throw new Error(`Expected [${mailableClass.name}] not to be sent, but it was sent ${matches.length} time(s).`)
51
+ }
52
+ }
53
+
54
+ assertQueued(mailableClass: new (...args: any[]) => Mailable, count?: number): void {
55
+ const matches = this._queued.filter(m => m instanceof mailableClass)
56
+ if (matches.length === 0) {
57
+ throw new Error(`Expected [${mailableClass.name}] to be queued, but it was not.`)
58
+ }
59
+ if (count !== undefined && matches.length !== count) {
60
+ throw new Error(`Expected [${mailableClass.name}] to be queued ${count} time(s), but was queued ${matches.length} time(s).`)
61
+ }
62
+ }
63
+
64
+ assertNothingSent(): void {
65
+ if (this._sentMailables.length > 0) {
66
+ throw new Error(`Expected no mailables to be sent, but ${this._sentMailables.length} were sent.`)
67
+ }
68
+ }
69
+
70
+ assertNothingQueued(): void {
71
+ if (this._queued.length > 0) {
72
+ throw new Error(`Expected no mailables to be queued, but ${this._queued.length} were queued.`)
73
+ }
74
+ }
75
+
76
+ // ── Inspection ──────────────────────────────────────────────────────────
77
+
78
+ sent(): Message[] { return [...this._sent] }
79
+ sentMailables(): Mailable[] { return [...this._sentMailables] }
80
+ queued(): Mailable[] { return [...this._queued] }
81
+
82
+ reset(): void {
83
+ this._sent = []
84
+ this._sentMailables = []
85
+ this._queued = []
86
+ }
87
+ }