@technomoron/mail-magic 1.0.38 → 1.0.41

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.
Files changed (93) hide show
  1. package/CHANGES +48 -0
  2. package/README.md +7 -2
  3. package/dist/cjs/index.d.ts +1 -0
  4. package/dist/cjs/index.js +1 -1
  5. package/dist/cjs/package.json +1 -1
  6. package/dist/esm/api/assets.d.ts +9 -0
  7. package/dist/esm/api/auth.d.ts +2 -0
  8. package/dist/esm/api/auth.js +17 -8
  9. package/dist/esm/api/forms.d.ts +9 -0
  10. package/dist/esm/api/forms.js +7 -4
  11. package/dist/esm/api/mailer.d.ts +11 -0
  12. package/dist/esm/api/mailer.js +8 -7
  13. package/dist/esm/bin/mail-magic.d.ts +2 -0
  14. package/dist/esm/bin/mail-magic.js +0 -0
  15. package/dist/esm/index.d.ts +12 -0
  16. package/dist/esm/models/db.d.ts +5 -0
  17. package/dist/esm/models/db.js +2 -2
  18. package/dist/esm/models/domain.d.ts +24 -0
  19. package/dist/esm/models/domain.js +0 -2
  20. package/dist/esm/models/form.d.ts +50 -0
  21. package/dist/esm/models/form.js +16 -15
  22. package/dist/esm/models/init.d.ts +12 -0
  23. package/dist/esm/models/init.js +7 -28
  24. package/dist/esm/models/recipient.d.ts +24 -0
  25. package/dist/esm/models/recipient.js +0 -2
  26. package/dist/esm/models/txmail.d.ts +42 -0
  27. package/dist/esm/models/txmail.js +0 -2
  28. package/dist/esm/models/user.d.ts +33 -0
  29. package/dist/esm/models/user.js +0 -2
  30. package/dist/esm/server.d.ts +8 -0
  31. package/dist/esm/store/envloader.d.ts +188 -0
  32. package/dist/esm/store/envloader.js +14 -4
  33. package/dist/esm/store/store.d.ts +37 -0
  34. package/dist/esm/store/store.js +6 -7
  35. package/dist/esm/swagger.d.ts +10 -0
  36. package/dist/esm/types.d.ts +32 -0
  37. package/dist/esm/util/captcha.d.ts +7 -0
  38. package/dist/esm/util/captcha.js +4 -1
  39. package/dist/esm/util/email.d.ts +3 -0
  40. package/dist/esm/util/form-replyto.d.ts +6 -0
  41. package/dist/esm/util/form-submission.d.ts +24 -0
  42. package/dist/esm/util/forms.d.ts +140 -0
  43. package/dist/esm/util/forms.js +18 -32
  44. package/dist/esm/util/paths.d.ts +15 -0
  45. package/dist/esm/util/paths.js +17 -0
  46. package/dist/esm/util/ratelimit.d.ts +16 -0
  47. package/dist/esm/util/ratelimit.js +6 -1
  48. package/dist/esm/util/route.d.ts +1 -0
  49. package/dist/esm/util/shared-template-flatten.d.ts +17 -0
  50. package/dist/esm/util/shared-template-flatten.js +41 -0
  51. package/dist/esm/util/uploads.d.ts +10 -0
  52. package/dist/esm/util/utils.d.ts +27 -0
  53. package/dist/esm/util.d.ts +7 -0
  54. package/docs/swagger/openapi.json +16 -12
  55. package/docs/tutorial.md +2 -2
  56. package/examples/.env-dist +21 -0
  57. package/examples/README.md +74 -0
  58. package/examples/data/example.test/form-template/base.njk +4 -0
  59. package/examples/data/example.test/form-template/en/base.njk +1 -0
  60. package/examples/data/example.test/form-template/en/change-password.njk +5 -0
  61. package/examples/data/example.test/form-template/en/confirm-account.njk +5 -0
  62. package/examples/data/example.test/form-template/en/contact.njk +5 -0
  63. package/examples/data/example.test/form-template/en/partials/fields.njk +5 -0
  64. package/examples/data/example.test/form-template/en/welcome-signup.njk +5 -0
  65. package/examples/data/example.test/form-template/nb/base.njk +1 -0
  66. package/examples/data/example.test/form-template/nb/change-password.njk +5 -0
  67. package/examples/data/example.test/form-template/nb/confirm-account.njk +5 -0
  68. package/examples/data/example.test/form-template/nb/contact.njk +5 -0
  69. package/examples/data/example.test/form-template/nb/partials/fields.njk +5 -0
  70. package/examples/data/example.test/form-template/nb/welcome-signup.njk +5 -0
  71. package/examples/data/example.test/form-template/partials/header.njk +1 -0
  72. package/examples/data/example.test/tx-template/base.njk +16 -0
  73. package/examples/data/example.test/tx-template/en/base.njk +1 -0
  74. package/examples/data/example.test/tx-template/en/change-password.njk +7 -0
  75. package/examples/data/example.test/tx-template/en/confirm.njk +6 -0
  76. package/examples/data/example.test/tx-template/en/invoice.njk +8 -0
  77. package/examples/data/example.test/tx-template/en/partials/header.njk +1 -0
  78. package/examples/data/example.test/tx-template/en/partials/line-items.njk +14 -0
  79. package/examples/data/example.test/tx-template/en/receipt.njk +7 -0
  80. package/examples/data/example.test/tx-template/en/welcome.njk +5 -0
  81. package/examples/data/example.test/tx-template/nb/base.njk +1 -0
  82. package/examples/data/example.test/tx-template/nb/change-password.njk +6 -0
  83. package/examples/data/example.test/tx-template/nb/confirm.njk +6 -0
  84. package/examples/data/example.test/tx-template/nb/invoice.njk +7 -0
  85. package/examples/data/example.test/tx-template/nb/partials/header.njk +1 -0
  86. package/examples/data/example.test/tx-template/nb/receipt.njk +6 -0
  87. package/examples/data/example.test/tx-template/nb/welcome.njk +5 -0
  88. package/examples/data/example.test/tx-template/partials/header.njk +7 -0
  89. package/examples/data/init-data.json +213 -0
  90. package/examples/scripts/mm-api.ts +206 -0
  91. package/examples/scripts/public-form.ts +100 -0
  92. package/examples/scripts/send-messages.ts +114 -0
  93. package/package.json +90 -85
@@ -0,0 +1,15 @@
1
+ export declare const SEGMENT_PATTERN: RegExp;
2
+ export declare function normalizeSubdir(value: string): string;
3
+ export declare function assertSafeRelativePath(filename: string, label: string): string;
4
+ export declare function buildFormSlugAndFilename(params: {
5
+ domainName: string;
6
+ domainLocale: string;
7
+ userLocale: string;
8
+ idname: string;
9
+ locale: string;
10
+ }): {
11
+ localeSlug: string;
12
+ slug: string;
13
+ filename: string;
14
+ };
15
+ export declare function buildAssetUrl(baseUrl: string, route: string, domainName: string, assetPath: string): string;
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import { ApiError } from '@technomoron/api-server-base';
3
+ import { normalizeSlug } from './utils.js';
3
4
  export const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
4
5
  export function normalizeSubdir(value) {
5
6
  if (!value) {
@@ -27,6 +28,22 @@ export function assertSafeRelativePath(filename, label) {
27
28
  }
28
29
  return normalized;
29
30
  }
31
+ export function buildFormSlugAndFilename(params) {
32
+ const domainSlug = normalizeSlug(params.domainName);
33
+ const formSlug = normalizeSlug(params.idname);
34
+ const localeSlug = normalizeSlug(params.locale || params.domainLocale || params.userLocale || '');
35
+ const slug = `${domainSlug}${localeSlug ? '-' + localeSlug : ''}-${formSlug}`;
36
+ const filenameParts = [domainSlug, 'form-template'];
37
+ if (localeSlug) {
38
+ filenameParts.push(localeSlug);
39
+ }
40
+ filenameParts.push(formSlug);
41
+ let filename = path.join(...filenameParts);
42
+ if (!filename.endsWith('.njk')) {
43
+ filename += '.njk';
44
+ }
45
+ return { localeSlug, slug, filename };
46
+ }
30
47
  export function buildAssetUrl(baseUrl, route, domainName, assetPath) {
31
48
  const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
32
49
  const normalizedRoute = route ? (route.startsWith('/') ? route : `/${route}`) : '';
@@ -0,0 +1,16 @@
1
+ import { ApiRequest } from '@technomoron/api-server-base';
2
+ export type RateLimitDecision = {
3
+ allowed: boolean;
4
+ retryAfterSec: number;
5
+ };
6
+ export declare class FixedWindowRateLimiter {
7
+ private readonly maxKeys;
8
+ private readonly buckets;
9
+ constructor(maxKeys?: number);
10
+ check(key: string, max: number, windowMs: number): RateLimitDecision;
11
+ private prune;
12
+ }
13
+ export declare function enforceFormRateLimit(limiter: FixedWindowRateLimiter, env: {
14
+ FORM_RATE_LIMIT_WINDOW_SEC: number;
15
+ FORM_RATE_LIMIT_MAX: number;
16
+ }, apireq: ApiRequest): void;
@@ -39,8 +39,13 @@ export class FixedWindowRateLimiter {
39
39
  }
40
40
  export function enforceFormRateLimit(limiter, env, apireq) {
41
41
  const clientIp = apireq.getClientIp() ?? '';
42
+ if (!clientIp) {
43
+ // Cannot rate-limit without a resolvable client IP; skip to avoid collapsing
44
+ // all IP-unknown requests into a single shared bucket.
45
+ return;
46
+ }
42
47
  const windowMs = Math.max(0, env.FORM_RATE_LIMIT_WINDOW_SEC) * 1000;
43
- const decision = limiter.check(`form-message:${clientIp || 'unknown'}`, env.FORM_RATE_LIMIT_MAX, windowMs);
48
+ const decision = limiter.check(`form-message:${clientIp}`, env.FORM_RATE_LIMIT_MAX, windowMs);
44
49
  if (!decision.allowed) {
45
50
  apireq.res.set('Retry-After', String(decision.retryAfterSec));
46
51
  throw new ApiError({ code: 429, message: 'Too many form submissions; try again later' });
@@ -0,0 +1 @@
1
+ export declare function normalizeRoute(value: string, fallback?: string): string;
@@ -0,0 +1,17 @@
1
+ export type FlattenedAsset = {
2
+ filename: string;
3
+ path: string;
4
+ cid?: string;
5
+ };
6
+ export type FlattenWithAssetsOptions = {
7
+ domainRoot: string;
8
+ templateKey: string;
9
+ baseUrl: string;
10
+ assetFormatter: (urlPath: string) => string;
11
+ normalizeInlineCid?: (urlPath: string) => string;
12
+ };
13
+ export type FlattenWithAssetsResult = {
14
+ html: string;
15
+ assets: FlattenedAsset[];
16
+ };
17
+ export declare function flattenTemplateWithAssets(options: FlattenWithAssetsOptions): FlattenWithAssetsResult;
@@ -0,0 +1,41 @@
1
+ // AUTO-GENERATED by scripts/sync-shared-code.cjs. Do not edit directly.
2
+ import { Unyuck } from '@technomoron/unyuck';
3
+ function defaultInlineAssetCid(urlPath) {
4
+ const normalized = String(urlPath || '')
5
+ .trim()
6
+ .replace(/\\/g, '/');
7
+ const safe = normalized.replace(/[^A-Za-z0-9._-]/g, '_').replace(/_+/g, '_');
8
+ return (safe || 'asset').slice(0, 200);
9
+ }
10
+ export function flattenTemplateWithAssets(options) {
11
+ const processor = new Unyuck({
12
+ basePath: options.domainRoot,
13
+ baseUrl: options.baseUrl,
14
+ collectAssets: true,
15
+ assetFormatter: (ctx) => options.assetFormatter(ctx.urlPath)
16
+ });
17
+ const { html: mergedHtml, assets } = processor.flattenWithAssets(options.templateKey);
18
+ let html = mergedHtml;
19
+ const normalizeCid = options.normalizeInlineCid ?? defaultInlineAssetCid;
20
+ const mappedAssets = assets.map((asset) => {
21
+ const rel = asset.filename.replace(/\\/g, '/');
22
+ const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
23
+ return {
24
+ filename: urlPath,
25
+ path: asset.path,
26
+ cid: asset.cid ? normalizeCid(urlPath) : undefined
27
+ };
28
+ });
29
+ for (const asset of assets) {
30
+ if (!asset.cid) {
31
+ continue;
32
+ }
33
+ const rel = asset.filename.replace(/\\/g, '/');
34
+ const urlPath = rel.startsWith('assets/') ? rel.slice('assets/'.length) : rel;
35
+ const desiredCid = normalizeCid(urlPath);
36
+ if (asset.cid !== desiredCid) {
37
+ html = html.split(`cid:${asset.cid}`).join(`cid:${desiredCid}`);
38
+ }
39
+ }
40
+ return { html, assets: mappedAssets };
41
+ }
@@ -0,0 +1,10 @@
1
+ import type { UploadedFile } from '../types.js';
2
+ export declare function buildAttachments(rawFiles: UploadedFile[]): {
3
+ attachments: Array<{
4
+ filename: string;
5
+ path: string;
6
+ }>;
7
+ attachmentMap: Record<string, string>;
8
+ };
9
+ export declare function cleanupUploadedFiles(files: UploadedFile[]): Promise<void>;
10
+ export declare function moveUploadedFiles(files: UploadedFile[], targetDir: string): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { api_domain } from '../models/domain.js';
2
+ import { api_user } from '../models/user.js';
3
+ import type { RequestMeta } from '../types.js';
4
+ import type { Response } from 'express';
5
+ /**
6
+ * Normalize a string into a safe identifier for slugs, filenames, etc.
7
+ *
8
+ * - Lowercases all characters
9
+ * - Replaces any character that is not `a-z`, `0-9`, `-`, '.' or `_` with `-`
10
+ * - Collapses multiple consecutive dashes into one
11
+ * - Trims leading and trailing dashes
12
+ *
13
+ * Examples:
14
+ * normalizeSlug("Hello World!") -> "hello-world"
15
+ * normalizeSlug(" Áccêntš ") -> "cc-nt"
16
+ * normalizeSlug("My--Slug__Test") -> "my-slug__test"
17
+ */
18
+ export declare function normalizeSlug(input: string): string;
19
+ export declare function user_and_domain(domain_id: number): Promise<{
20
+ user: api_user;
21
+ domain: api_domain;
22
+ }>;
23
+ export declare function buildRequestMeta(rawReq: unknown): RequestMeta;
24
+ export declare function decodeComponent(value: string | string[] | undefined): string;
25
+ export declare function getBodyValue(body: Record<string, unknown>, ...keys: string[]): string;
26
+ export declare function normalizeBoolean(value: unknown): boolean;
27
+ export declare function sendFileAsync(res: Pick<Response, 'sendFile'>, file: string, options?: Parameters<Response['sendFile']>[1]): Promise<void>;
@@ -0,0 +1,7 @@
1
+ export * from './util/utils.js';
2
+ export * from './util/email.js';
3
+ export * from './util/paths.js';
4
+ export * from './util/uploads.js';
5
+ export * from './util/form-replyto.js';
6
+ export * from './util/form-submission.js';
7
+ export * from './util/forms.js';
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Mail Magic API",
5
- "version": "1.0.33",
5
+ "version": "1.0.41",
6
6
  "description": "OpenAPI definition for the Mail Magic server. Authenticated endpoints require an API key provided as `Authorization: Bearer apikey-<token>`."
7
7
  },
8
8
  "servers": [
@@ -897,7 +897,7 @@
897
897
  "properties": {
898
898
  "domain": {
899
899
  "type": "string",
900
- "description": "Domain name owned by the API key user."
900
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
901
901
  },
902
902
  "name": {
903
903
  "type": "string",
@@ -920,13 +920,14 @@
920
920
  "default": ""
921
921
  }
922
922
  },
923
- "required": ["domain", "name", "template"]
923
+ "required": ["name", "template"]
924
924
  },
925
925
  "TxSendRequest": {
926
926
  "type": "object",
927
927
  "properties": {
928
928
  "domain": {
929
- "type": "string"
929
+ "type": "string",
930
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
930
931
  },
931
932
  "name": {
932
933
  "type": "string",
@@ -969,14 +970,15 @@
969
970
  "description": "Custom email headers."
970
971
  }
971
972
  },
972
- "required": ["domain", "name", "rcpt"]
973
+ "required": ["name", "rcpt"]
973
974
  },
974
975
  "TxSendMultipartRequest": {
975
976
  "type": "object",
976
977
  "description": "Multipart version of TxSendRequest. Attach files in any multipart field; `files` is a conventional field name.",
977
978
  "properties": {
978
979
  "domain": {
979
- "type": "string"
980
+ "type": "string",
981
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
980
982
  },
981
983
  "name": {
982
984
  "type": "string"
@@ -1012,7 +1014,7 @@
1012
1014
  }
1013
1015
  }
1014
1016
  },
1015
- "required": ["domain", "name", "rcpt"]
1017
+ "required": ["name", "rcpt"]
1016
1018
  },
1017
1019
  "TxSendResponseData": {
1018
1020
  "type": "object",
@@ -1031,7 +1033,8 @@
1031
1033
  "type": "object",
1032
1034
  "properties": {
1033
1035
  "domain": {
1034
- "type": "string"
1036
+ "type": "string",
1037
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
1035
1038
  },
1036
1039
  "idname": {
1037
1040
  "type": "string",
@@ -1053,7 +1056,7 @@
1053
1056
  "type": "string"
1054
1057
  }
1055
1058
  },
1056
- "required": ["domain", "idname", "email"]
1059
+ "required": ["idname", "email"]
1057
1060
  },
1058
1061
  "FormRecipientUpsertResponseData": {
1059
1062
  "type": "object",
@@ -1075,7 +1078,8 @@
1075
1078
  "type": "object",
1076
1079
  "properties": {
1077
1080
  "domain": {
1078
- "type": "string"
1081
+ "type": "string",
1082
+ "description": "Optional. Domain name. If omitted, the API key's default domain is used."
1079
1083
  },
1080
1084
  "idname": {
1081
1085
  "type": "string",
@@ -1131,7 +1135,7 @@
1131
1135
  "type": "boolean"
1132
1136
  }
1133
1137
  },
1134
- "required": ["domain", "idname", "template", "sender", "recipient"]
1138
+ "required": ["idname", "template", "sender", "recipient"]
1135
1139
  },
1136
1140
  "FormTemplateUpsertResponseData": {
1137
1141
  "type": "object",
@@ -1262,7 +1266,7 @@
1262
1266
  },
1263
1267
  "FormMessageResponseData": {
1264
1268
  "type": "object",
1265
- "description": "On success, data is usually an empty object. Some internal failures may return an object with an `error` string.",
1269
+ "description": "On success, data is an empty object.",
1266
1270
  "additionalProperties": true
1267
1271
  },
1268
1272
  "AssetsUploadMultipartRequest": {
package/docs/tutorial.md CHANGED
@@ -19,7 +19,7 @@ export CONFIG_ROOT=$(realpath ../myorg-config)
19
19
 
20
20
  Update your `.env` (or runtime environment) to point at the new workspace:
21
21
 
22
- ```
22
+ ```dotenv
23
23
  API_TOKEN_PEPPER=<generate-a-long-random-string>
24
24
  CONFIG_PATH=${CONFIG_ROOT}
25
25
  DB_AUTO_RELOAD=1 # optional: hot-reload init-data and templates
@@ -41,7 +41,7 @@ mkdir -p \
41
41
 
42
42
  The resulting tree should look like this (logo placement shown for clarity — add the file in step 4):
43
43
 
44
- ```
44
+ ```text
45
45
  myorg-config/
46
46
  ├── init-data.json
47
47
  └── myorg.com/
@@ -0,0 +1,21 @@
1
+ NODE_ENV=development
2
+ API_PORT=3776
3
+ API_HOST=127.0.0.1
4
+ API_URL=http://127.0.0.1:3776/api
5
+ API_BASE_PATH=/api
6
+ ASSET_ROUTE=/asset
7
+ CONFIG_PATH=./data
8
+ DB_TYPE=sqlite
9
+ DB_NAME=./maildata.db
10
+ DB_FORCE_SYNC=true
11
+ DB_AUTO_RELOAD=false
12
+ DB_SYNC_ALTER=false
13
+ DB_LOG=false
14
+ DEBUG=false
15
+ AUTOESCAPE_HTML=true
16
+ UPLOAD_PATH=./{domain}/uploads
17
+ SMTP_HOST=127.0.0.1
18
+ SMTP_PORT=1025
19
+ SMTP_SECURE=false
20
+ SMTP_TLS_REJECT=false
21
+ API_TOKEN_PEPPER=example-token-pepper-value
@@ -0,0 +1,74 @@
1
+ # Mail Magic Examples
2
+
3
+ The canonical example setup for Mail Magic. Shipped inside the `@technomoron/mail-magic` package so it is
4
+ accessible without a private repo clone:
5
+
6
+ ```bash
7
+ ls node_modules/@technomoron/mail-magic/examples/
8
+ ```
9
+
10
+ It contains:
11
+
12
+ - `data/` — runnable server config: `init-data.json` + `example.test/` domain with transactional and form templates
13
+ - `scripts/` — helper TypeScript scripts for sending messages and testing the API
14
+ - `.env-dist` — example environment file for local development
15
+
16
+ Template families: welcome, confirm, change-password, receipt, invoice (tx) and contact, welcome-signup,
17
+ confirm-account, change-password (form). Locale variants: `en`, `nb`.
18
+
19
+ ## Run The Example Server
20
+
21
+ From repo root:
22
+
23
+ ```bash
24
+ pnpm examples
25
+ ```
26
+
27
+ This runs the `mail-magic` server bin with:
28
+
29
+ - env file: `packages/server/examples/.env-dist`
30
+ - config dir: `packages/server/examples/data`
31
+ - default API host: `http://127.0.0.1:3776`
32
+
33
+ ## Default Demo Credentials
34
+
35
+ Defined in `data/init-data.json`:
36
+
37
+ - domain: `example.test`
38
+ - token: `example-token`
39
+
40
+ ```text
41
+ Authorization: Bearer apikey-example-token
42
+ ```
43
+
44
+ ## Validate Templates With `mm-cli compile`
45
+
46
+ From repo root:
47
+
48
+ ```bash
49
+ node packages/cli/dist/cli.js compile \
50
+ --input ./packages/server/examples/data \
51
+ --output /tmp/mm-compiled-examples \
52
+ --domain example.test
53
+ ```
54
+
55
+ ## Helper Scripts
56
+
57
+ From repo root (requires `tsx`):
58
+
59
+ ```bash
60
+ tsx packages/server/examples/scripts/send-messages.ts
61
+ tsx packages/server/examples/scripts/public-form.ts
62
+ tsx packages/server/examples/scripts/mm-api.ts template \
63
+ --file packages/server/examples/data/example.test/tx-template/en/welcome.njk \
64
+ --name welcome --domain example.test
65
+ ```
66
+
67
+ ## Production Adaptation
68
+
69
+ Copy the `data/` directory and `.env-dist` to your project and adapt:
70
+
71
+ 1. `.env-dist` → `.env`: set `API_TOKEN_PEPPER`, SMTP settings, `API_URL`, DB path.
72
+ 2. `data/init-data.json`: users, domains, tokens, sender/recipient addresses.
73
+ 3. `data/<your-domain>/tx-template/*`: brand, content, locale text, assets.
74
+ 4. `data/<your-domain>/form-template/*`: subjects, recipient routing, exposed fields.
@@ -0,0 +1,4 @@
1
+ {% include 'partials/header.njk' %}
2
+ <h1>{{ title or 'Form message' }}</h1>
3
+ {% block content %}{% endblock %}
4
+ <p style="font-size: 12px; color: #666;">Generated at {{ _meta_.ts or '' }}</p>
@@ -0,0 +1 @@
1
+ {% extends '../base.njk' %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>Password change form submission.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>New account confirmation request.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>New contact submission.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ <ul>
2
+ {% for key, value in _fields_ %}
3
+ <li><strong>{{ key }}</strong>: {{ value }}</li>
4
+ {% endfor %}
5
+ </ul>
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>New welcome signup.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1 @@
1
+ {% extends '../base.njk' %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>Skjema for passordendring.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>Ny bekreft-konto foresporsel.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>Ny kontakthenvendelse.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ <ul>
2
+ {% for key, value in _fields_ %}
3
+ <li><strong>{{ key }}</strong>: {{ value }}</li>
4
+ {% endfor %}
5
+ </ul>
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <p>Ny registrering.</p>
4
+ {% include 'partials/fields.njk' %}
5
+ {% endblock %}
@@ -0,0 +1 @@
1
+ <p><strong>{{ brand_name or 'Mail Magic Forms' }}</strong></p>
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="{{ locale or 'en' }}">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>{{ subject or 'Mail Magic' }}</title>
7
+ </head>
8
+ <body style="font-family: Arial, Helvetica, sans-serif; color: #102030;">
9
+ {% include 'partials/header.njk' %}
10
+ <main>
11
+ {% block content %}{% endblock %}
12
+ </main>
13
+ <hr />
14
+ <p style="font-size: 12px; color: #666;">{{ company_name or 'Example Inc.' }} - {{ support_email or 'support@example.test' }}</p>
15
+ </body>
16
+ </html>
@@ -0,0 +1 @@
1
+ {% extends '../base.njk' %}
@@ -0,0 +1,7 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Change your password</h1>
4
+ <p>Use this secure link to set a new password:</p>
5
+ <p><a href="{{ reset_url }}">Reset password</a></p>
6
+ <p style="font-size: 12px; color: #666;">Link expires in {{ expires_minutes or 30 }} minutes.</p>
7
+ {% endblock %}
@@ -0,0 +1,6 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Confirm your email</h1>
4
+ <p>Hi {{ name }}, click below to confirm your account.</p>
5
+ <p><a href="{{ confirm_url }}">Confirm account</a></p>
6
+ {% endblock %}
@@ -0,0 +1,8 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Invoice {{ invoice_no }}</h1>
4
+ <p>Hello {{ name }}, here is your invoice summary.</p>
5
+ {% include 'partials/line-items.njk' %}
6
+ <p><strong>Total:</strong> {{ currency or 'USD' }} {{ total }}</p>
7
+ <p>Due: {{ due_date }}</p>
8
+ {% endblock %}
@@ -0,0 +1 @@
1
+ {% include '../../partials/header.njk' %}
@@ -0,0 +1,14 @@
1
+ <table role="presentation" width="100%" cellpadding="6" cellspacing="0" style="border-collapse: collapse; margin-top: 12px;">
2
+ <tr style="background: #f5f7fb;">
3
+ <th align="left">Item</th>
4
+ <th align="right">Qty</th>
5
+ <th align="right">Price</th>
6
+ </tr>
7
+ {% for item in items or [] %}
8
+ <tr>
9
+ <td>{{ item.name }}</td>
10
+ <td align="right">{{ item.qty }}</td>
11
+ <td align="right">{{ currency or 'USD' }} {{ item.price }}</td>
12
+ </tr>
13
+ {% endfor %}
14
+ </table>
@@ -0,0 +1,7 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Payment receipt {{ receipt_no }}</h1>
4
+ <p>Thanks for your payment, {{ name }}.</p>
5
+ <p>Amount: {{ currency or 'USD' }} {{ amount }}</p>
6
+ <p>Date: {{ paid_at }}</p>
7
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Welcome, {{ name }}!</h1>
4
+ <p>Your account is ready. You can now sign in and start using {{ brand_name or 'Mail Magic' }}.</p>
5
+ {% endblock %}
@@ -0,0 +1 @@
1
+ {% extends '../base.njk' %}
@@ -0,0 +1,6 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Endre passord</h1>
4
+ <p>Bruk lenken under for a sette nytt passord:</p>
5
+ <p><a href="{{ reset_url }}">Nullstill passord</a></p>
6
+ {% endblock %}
@@ -0,0 +1,6 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Bekreft e-post</h1>
4
+ <p>Hei {{ name }}, klikk under for a bekrefte kontoen din.</p>
5
+ <p><a href="{{ confirm_url }}">Bekreft konto</a></p>
6
+ {% endblock %}
@@ -0,0 +1,7 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Faktura {{ invoice_no }}</h1>
4
+ <p>Hei {{ name }}, her er fakturaen din.</p>
5
+ <p><strong>Sum:</strong> {{ currency or 'NOK' }} {{ total }}</p>
6
+ <p>Forfall: {{ due_date }}</p>
7
+ {% endblock %}
@@ -0,0 +1 @@
1
+ {% include '../../partials/header.njk' %}
@@ -0,0 +1,6 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Kvittering {{ receipt_no }}</h1>
4
+ <p>Takk for betalingen, {{ name }}.</p>
5
+ <p>Belop: {{ currency or 'NOK' }} {{ amount }}</p>
6
+ {% endblock %}
@@ -0,0 +1,5 @@
1
+ {% extends 'base.njk' %}
2
+ {% block content %}
3
+ <h1>Velkommen, {{ name }}!</h1>
4
+ <p>Kontoen din er klar, og du kan begynne med en gang.</p>
5
+ {% endblock %}
@@ -0,0 +1,7 @@
1
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 24px;">
2
+ <tr>
3
+ <td style="padding: 12px 0; border-bottom: 2px solid #dbe2ea;">
4
+ <strong>{{ brand_name or 'Mail Magic' }}</strong>
5
+ </td>
6
+ </tr>
7
+ </table>