pipework 0.62.0 → 0.63.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.
- package/CHANGELOG.md +8 -0
- package/REFERENCE.md +31 -0
- package/dist/core/config/namespace.d.ts +1 -0
- package/dist/core/config/namespace.d.ts.map +1 -1
- package/dist/core/config/schema.d.ts +1 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +2 -0
- package/dist/core/config/schema.js.map +1 -1
- package/dist/data/migrate/generate.d.ts.map +1 -1
- package/dist/data/migrate/generate.js +2 -1
- package/dist/data/migrate/generate.js.map +1 -1
- package/dist/data/migrate/internal-definitions.d.ts.map +1 -1
- package/dist/data/migrate/internal-definitions.js +5 -0
- package/dist/data/migrate/internal-definitions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/infra/email/audit.d.ts +3 -0
- package/dist/infra/email/audit.d.ts.map +1 -0
- package/dist/infra/email/audit.js +25 -0
- package/dist/infra/email/audit.js.map +1 -0
- package/dist/infra/email/index.d.ts +5 -0
- package/dist/infra/email/index.d.ts.map +1 -0
- package/dist/infra/email/index.js +5 -0
- package/dist/infra/email/index.js.map +1 -0
- package/dist/infra/email/mail.d.ts +69 -0
- package/dist/infra/email/mail.d.ts.map +1 -0
- package/dist/infra/email/mail.js +144 -0
- package/dist/infra/email/mail.js.map +1 -0
- package/dist/infra/email/namespace.d.ts +15 -0
- package/dist/infra/email/namespace.d.ts.map +1 -0
- package/dist/infra/email/namespace.js +15 -0
- package/dist/infra/email/namespace.js.map +1 -0
- package/dist/infra/email/providers.d.ts +42 -0
- package/dist/infra/email/providers.d.ts.map +1 -0
- package/dist/infra/email/providers.js +71 -0
- package/dist/infra/email/providers.js.map +1 -0
- package/dist/infra/email/schema.d.ts +74 -0
- package/dist/infra/email/schema.d.ts.map +1 -0
- package/dist/infra/email/schema.js +39 -0
- package/dist/infra/email/schema.js.map +1 -0
- package/dist/infra/email/sigv4.d.ts +24 -0
- package/dist/infra/email/sigv4.d.ts.map +1 -0
- package/dist/infra/email/sigv4.js +55 -0
- package/dist/infra/email/sigv4.js.map +1 -0
- package/dist/infra/email/sns.d.ts +32 -0
- package/dist/infra/email/sns.d.ts.map +1 -0
- package/dist/infra/email/sns.js +173 -0
- package/dist/infra/email/sns.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { sql } from '../../data/db/sql.js';
|
|
2
|
+
import { pipeSystem } from '../../data/db/db.js';
|
|
3
|
+
import { schema } from '../../data/schema/namespace.js';
|
|
4
|
+
import { fitting } from '../../request/di/index.js';
|
|
5
|
+
import { getContext } from '../../request/context/store.js';
|
|
6
|
+
import { ValidationError } from '../../core/errors/index.js';
|
|
7
|
+
import { getBaseLogger } from '../../core/logging/index.js';
|
|
8
|
+
import { __flowStep } from '../../trace/runtime/flow-step.js';
|
|
9
|
+
import { auditEmail } from './audit.js';
|
|
10
|
+
export const SEND_JOB = 'pipework.email.send';
|
|
11
|
+
const ADDRESS_RE = /^[^\s@<>,;"]+@[^\s@<>,;"]+$/;
|
|
12
|
+
export function normalizeAddress(address) {
|
|
13
|
+
return address.trim().toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Creates the transactional email interface.
|
|
17
|
+
*
|
|
18
|
+
* `send(db, { to, template, data, key })` writes a `pipework_email_messages` row and
|
|
19
|
+
* enqueues a `pipework.email.send` job in the caller's transaction. The job renders the
|
|
20
|
+
* app's template and sends through the provider, at most once per key, skipping an
|
|
21
|
+
* address on the suppression list (status `suppressed`, no provider call). The
|
|
22
|
+
* provider call is a trace step and `Email:sent` / `Email:suppressed` / `Email:failed`
|
|
23
|
+
* audit records are written under it, in the trace of the flow that called `send`.
|
|
24
|
+
*
|
|
25
|
+
* Needs blueprint `email: {}` (tables `pipework_email_messages`, `pipework_email_suppressions`).
|
|
26
|
+
* A provider error returns the message to `queued` with `error` set and rethrows, so the
|
|
27
|
+
* queue retries (set `maxAttempts` on the queue); if the queue gives up the row keeps the error.
|
|
28
|
+
* A run claims the message with a conditional update (`status` `queued`, or `sending` for longer than
|
|
29
|
+
* `sendingStaleSeconds`), so a re-run of a job whose first run is still in flight does not call the provider.
|
|
30
|
+
* Residual window: a crash mid-send leaves the message `sending` for the stale window, after which a retry
|
|
31
|
+
* may send it once more; likewise if the provider accepted it and the process died before the row was marked `sent`. SES v2 has no idempotency token to close it.
|
|
32
|
+
* Message bodies and template data are never logged.
|
|
33
|
+
*/
|
|
34
|
+
export function createMail(config) {
|
|
35
|
+
if (config.provider.name === 'dev-stand-in' && config.environment === 'production') {
|
|
36
|
+
throw new Error('[pipework] mail.devStandIn is refused in production.\n\n' +
|
|
37
|
+
' The stand-in writes messages to files instead of sending them.\n' +
|
|
38
|
+
' Configure a real provider, e.g. mail.ses({ region, credentials }).\n');
|
|
39
|
+
}
|
|
40
|
+
const staleSeconds = config.sendingStaleSeconds ?? 600;
|
|
41
|
+
const deliver = __flowStep({ ref: 'pipework/email:deliver' }, async (messageId) => {
|
|
42
|
+
const db = pipeSystem();
|
|
43
|
+
const rows = await db.execute(sql `SELECT * FROM pipework_email_messages WHERE id = ${messageId}::uuid`);
|
|
44
|
+
const row = rows[0];
|
|
45
|
+
if (row === undefined)
|
|
46
|
+
throw new Error(`[pipework] Email message ${messageId} does not exist.`);
|
|
47
|
+
const status = row['status'];
|
|
48
|
+
if (status === 'sent' || status === 'suppressed' || status === 'failed')
|
|
49
|
+
return { status };
|
|
50
|
+
const environment = getContext()?.instance.config.environment;
|
|
51
|
+
if (config.provider.name === 'dev-stand-in' && environment === 'production') {
|
|
52
|
+
throw new Error('[pipework] mail.devStandIn is refused in production.');
|
|
53
|
+
}
|
|
54
|
+
const to = row['to_address'];
|
|
55
|
+
const suppressed = await db.execute(sql `SELECT reason FROM pipework_email_suppressions WHERE address = ${normalizeAddress(to)}`);
|
|
56
|
+
if (suppressed.length > 0) {
|
|
57
|
+
await db.execute(sql `UPDATE pipework_email_messages SET status = 'suppressed' WHERE id = ${messageId}::uuid`);
|
|
58
|
+
await auditEmail('suppressed', messageId, { reason: suppressed[0]['reason'] });
|
|
59
|
+
getBaseLogger().info({ event: 'email suppressed send', messageId }, 'email skipped: address is suppressed');
|
|
60
|
+
return { status: 'suppressed' };
|
|
61
|
+
}
|
|
62
|
+
const templateName = row['template'];
|
|
63
|
+
const template = config.templates[templateName];
|
|
64
|
+
let rendered = null;
|
|
65
|
+
let renderError = '';
|
|
66
|
+
if (template === undefined) {
|
|
67
|
+
renderError = `no template named "${templateName}"`;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
try {
|
|
71
|
+
rendered = template(row['data']);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
renderError = `template "${templateName}" threw: ${err instanceof Error ? err.message : String(err)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (rendered === null) {
|
|
78
|
+
await db.execute(sql `UPDATE pipework_email_messages SET status = 'failed', error = ${renderError} WHERE id = ${messageId}::uuid`);
|
|
79
|
+
await auditEmail('failed', messageId, { error: renderError });
|
|
80
|
+
getBaseLogger().error({ event: 'email failed', messageId }, `email not sent: ${renderError}`);
|
|
81
|
+
return { status: 'failed' };
|
|
82
|
+
}
|
|
83
|
+
const claimed = await db.execute(sql `
|
|
84
|
+
UPDATE pipework_email_messages SET status = 'sending', sending_at = now()
|
|
85
|
+
WHERE id = ${messageId}::uuid
|
|
86
|
+
AND (status = 'queued' OR (status = 'sending' AND sending_at < now() - make_interval(secs => ${staleSeconds})))
|
|
87
|
+
RETURNING id`);
|
|
88
|
+
if (claimed.length === 0) {
|
|
89
|
+
getBaseLogger().info({ event: 'email send held elsewhere', messageId }, 'email not sent: another run holds the message or it is finished');
|
|
90
|
+
return { status: 'held' };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const sent = await config.provider.send({
|
|
94
|
+
messageId, from: config.from, to,
|
|
95
|
+
subject: rendered.subject, text: rendered.text,
|
|
96
|
+
...(rendered.html !== undefined ? { html: rendered.html } : {}),
|
|
97
|
+
...(config.configurationSet !== undefined ? { configurationSet: config.configurationSet } : {}),
|
|
98
|
+
});
|
|
99
|
+
await db.execute(sql `
|
|
100
|
+
UPDATE pipework_email_messages
|
|
101
|
+
SET status = 'sent', sending_at = NULL, provider_message_id = ${sent.providerMessageId}, sent_at = now(), error = NULL
|
|
102
|
+
WHERE id = ${messageId}::uuid`);
|
|
103
|
+
await auditEmail('sent', messageId, { provider: config.provider.name, providerMessageId: sent.providerMessageId });
|
|
104
|
+
return { status: 'sent' };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
108
|
+
await db.execute(sql `UPDATE pipework_email_messages SET status = 'queued', sending_at = NULL, error = ${message} WHERE id = ${messageId}::uuid`);
|
|
109
|
+
await auditEmail('failed', messageId, { error: message, willRetry: true });
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
const sendJob = fitting.use().job(SEND_JOB)
|
|
114
|
+
.input(schema.check.object({ messageId: schema.check.string() }))
|
|
115
|
+
.fit(async ({ input }) => deliver(input.messageId));
|
|
116
|
+
return {
|
|
117
|
+
async send(db, input) {
|
|
118
|
+
if (config.templates[input.template] === undefined) {
|
|
119
|
+
throw new ValidationError({ template: [`no template named "${input.template}" (have: ${Object.keys(config.templates).join(', ')})`] }, `mail.send: unknown template "${input.template}".`);
|
|
120
|
+
}
|
|
121
|
+
if (!ADDRESS_RE.test(input.to.trim())) {
|
|
122
|
+
throw new ValidationError({ to: ['is not an email address'] }, 'mail.send: "to" is not an email address.');
|
|
123
|
+
}
|
|
124
|
+
if (input.key.trim() === '') {
|
|
125
|
+
throw new ValidationError({ key: ['must not be empty'] }, 'mail.send: "key" must not be empty.');
|
|
126
|
+
}
|
|
127
|
+
const traceId = getContext()?.trace?.id ?? null;
|
|
128
|
+
const inserted = await db.execute(sql `
|
|
129
|
+
INSERT INTO pipework_email_messages (key, to_address, template, data, trace_id)
|
|
130
|
+
VALUES (${input.key}, ${input.to.trim()}, ${input.template}, ${JSON.stringify(input.data ?? null)}::jsonb, ${traceId}::uuid)
|
|
131
|
+
ON CONFLICT (key) DO NOTHING
|
|
132
|
+
RETURNING id`);
|
|
133
|
+
if (inserted.length === 0) {
|
|
134
|
+
const existing = await db.execute(sql `SELECT id FROM pipework_email_messages WHERE key = ${input.key}`);
|
|
135
|
+
return { messageId: existing[0]['id'], queued: false };
|
|
136
|
+
}
|
|
137
|
+
const messageId = inserted[0]['id'];
|
|
138
|
+
await config.queue.enqueue(db, { type: SEND_JOB, payload: { messageId }, continueFlow: true });
|
|
139
|
+
return { messageId, queued: true };
|
|
140
|
+
},
|
|
141
|
+
jobs: [sendJob],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=mail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mail.js","sourceRoot":"","sources":["../../../src/infra/email/mail.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createMail } from './mail.js';
|
|
2
|
+
import { ses, devStandIn } from './providers.js';
|
|
3
|
+
import { snsWebhook } from './sns.js';
|
|
4
|
+
/** Mail namespace — transactional email through a job queue, SES v2 or a dev stand-in, bounce and complaint suppression. */
|
|
5
|
+
export declare const mail: {
|
|
6
|
+
/** Creates the mail interface: `send(db, { to, template, data, key })` in the caller's transaction, and the `jobs` that deliver. */
|
|
7
|
+
readonly create: typeof createMail;
|
|
8
|
+
/** Amazon SES v2 provider, SigV4-signed without the AWS SDK. */
|
|
9
|
+
readonly ses: typeof ses;
|
|
10
|
+
/** Development provider that writes each message to a file instead of sending; refused in production. */
|
|
11
|
+
readonly devStandIn: typeof devStandIn;
|
|
12
|
+
/** Inbound handler for SES bounce and complaint events delivered through SNS; suppresses the address. */
|
|
13
|
+
readonly snsWebhook: typeof snsWebhook;
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=namespace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"namespace.d.ts","sourceRoot":"","sources":["../../../src/infra/email/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAErC,4HAA4H;AAC5H,eAAO,MAAM,IAAI;IACf,oIAAoI;;IAEpI,gEAAgE;;IAEhE,yGAAyG;;IAEzG,yGAAyG;;CAEjG,CAAA"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createMail } from './mail.js';
|
|
2
|
+
import { ses, devStandIn } from './providers.js';
|
|
3
|
+
import { snsWebhook } from './sns.js';
|
|
4
|
+
/** Mail namespace — transactional email through a job queue, SES v2 or a dev stand-in, bounce and complaint suppression. */
|
|
5
|
+
export const mail = {
|
|
6
|
+
/** Creates the mail interface: `send(db, { to, template, data, key })` in the caller's transaction, and the `jobs` that deliver. */
|
|
7
|
+
create: createMail,
|
|
8
|
+
/** Amazon SES v2 provider, SigV4-signed without the AWS SDK. */
|
|
9
|
+
ses,
|
|
10
|
+
/** Development provider that writes each message to a file instead of sending; refused in production. */
|
|
11
|
+
devStandIn,
|
|
12
|
+
/** Inbound handler for SES bounce and complaint events delivered through SNS; suppresses the address. */
|
|
13
|
+
snsWebhook,
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=namespace.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../../src/infra/email/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAErC,4HAA4H;AAC5H,MAAM,CAAC,MAAM,IAAI,GAAG;IAClB,oIAAoI;IACpI,MAAM,EAAE,UAAU;IAClB,gEAAgE;IAChE,GAAG;IACH,yGAAyG;IACzG,UAAU;IACV,yGAAyG;IACzG,UAAU;CACF,CAAA"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type AwsCredentials } from './sigv4.js';
|
|
2
|
+
/** One rendered message handed to a provider. */
|
|
3
|
+
export interface OutgoingMessage {
|
|
4
|
+
/** The message row id; unique per message. */
|
|
5
|
+
readonly messageId: string;
|
|
6
|
+
readonly from: string;
|
|
7
|
+
readonly to: string;
|
|
8
|
+
readonly subject: string;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly html?: string;
|
|
11
|
+
readonly configurationSet?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Sends one rendered message. A throw means it was not accepted and may be retried. */
|
|
14
|
+
export interface MailProvider {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
send(message: OutgoingMessage): Promise<{
|
|
17
|
+
providerMessageId: string;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
export interface SesConfig {
|
|
21
|
+
readonly region: string;
|
|
22
|
+
/** Called for every send, so short-lived credentials can be refreshed by the app. */
|
|
23
|
+
readonly credentials: () => Promise<AwsCredentials>;
|
|
24
|
+
readonly configurationSet?: string;
|
|
25
|
+
/** Base URL instead of `https://email.<region>.amazonaws.com`; for tests against a local server. */
|
|
26
|
+
readonly endpoint?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Amazon SES v2 provider. Posts to `/v2/email/outbound-emails`, signed with SigV4
|
|
30
|
+
* (no AWS SDK). Credentials come from the app, never from the environment.
|
|
31
|
+
* A non-2xx answer throws with the status and AWS's message.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ses(config: SesConfig): MailProvider;
|
|
34
|
+
/**
|
|
35
|
+
* Development stand-in: writes each rendered message to `<dir>/<messageId>.json`
|
|
36
|
+
* (from, to, subject, text, html) and logs one line, sending nothing.
|
|
37
|
+
* `mail.create` refuses it when the environment is production.
|
|
38
|
+
*/
|
|
39
|
+
export declare function devStandIn(config: {
|
|
40
|
+
readonly dir: string;
|
|
41
|
+
}): MailProvider;
|
|
42
|
+
//# sourceMappingURL=providers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers.d.ts","sourceRoot":"","sources":["../../../src/infra/email/providers.ts"],"names":[],"mappings":"AAGA,OAAO,EAAU,KAAK,cAAc,EAAE,MAAM,YAAY,CAAA;AAExD,iDAAiD;AACjD,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CACnC;AAED,wFAAwF;AACxF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,IAAI,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC;QAAE,iBAAiB,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CACvE;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,qFAAqF;IACrF,QAAQ,CAAC,WAAW,EAAE,MAAM,OAAO,CAAC,cAAc,CAAC,CAAA;IACnD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAClC,oGAAoG;IACpG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,MAAM,EAAE,SAAS,GAAG,YAAY,CAsCnD;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,YAAY,CAYzE"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getBaseLogger } from '../../core/logging/index.js';
|
|
4
|
+
import { signV4 } from './sigv4.js';
|
|
5
|
+
/**
|
|
6
|
+
* Amazon SES v2 provider. Posts to `/v2/email/outbound-emails`, signed with SigV4
|
|
7
|
+
* (no AWS SDK). Credentials come from the app, never from the environment.
|
|
8
|
+
* A non-2xx answer throws with the status and AWS's message.
|
|
9
|
+
*/
|
|
10
|
+
export function ses(config) {
|
|
11
|
+
const base = (config.endpoint ?? `https://email.${config.region}.amazonaws.com`).replace(/\/+$/, '');
|
|
12
|
+
return {
|
|
13
|
+
name: 'ses',
|
|
14
|
+
async send(message) {
|
|
15
|
+
const set = message.configurationSet ?? config.configurationSet;
|
|
16
|
+
const body = JSON.stringify({
|
|
17
|
+
FromEmailAddress: message.from,
|
|
18
|
+
Destination: { ToAddresses: [message.to] },
|
|
19
|
+
Content: {
|
|
20
|
+
Simple: {
|
|
21
|
+
Subject: { Data: message.subject, Charset: 'UTF-8' },
|
|
22
|
+
Body: {
|
|
23
|
+
Text: { Data: message.text, Charset: 'UTF-8' },
|
|
24
|
+
...(message.html !== undefined ? { Html: { Data: message.html, Charset: 'UTF-8' } } : {}),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
...(set !== undefined ? { ConfigurationSetName: set } : {}),
|
|
29
|
+
});
|
|
30
|
+
const url = `${base}/v2/email/outbound-emails`;
|
|
31
|
+
const headers = signV4({
|
|
32
|
+
method: 'POST', url, headers: { 'content-type': 'application/json' }, body,
|
|
33
|
+
region: config.region, service: 'ses', credentials: await config.credentials(),
|
|
34
|
+
});
|
|
35
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
36
|
+
const raw = await res.text();
|
|
37
|
+
let parsed = {};
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch { /* not JSON */ }
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const detail = String(parsed['message'] ?? parsed['Message'] ?? raw.slice(0, 200));
|
|
44
|
+
throw new Error(`[pipework] SES refused the message: HTTP ${res.status} ${res.headers.get('x-amzn-errortype') ?? ''} ${detail}`.replace(/ +/g, ' '));
|
|
45
|
+
}
|
|
46
|
+
const id = parsed['MessageId'];
|
|
47
|
+
if (typeof id !== 'string')
|
|
48
|
+
throw new Error('[pipework] SES answered without a MessageId.');
|
|
49
|
+
return { providerMessageId: id };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Development stand-in: writes each rendered message to `<dir>/<messageId>.json`
|
|
55
|
+
* (from, to, subject, text, html) and logs one line, sending nothing.
|
|
56
|
+
* `mail.create` refuses it when the environment is production.
|
|
57
|
+
*/
|
|
58
|
+
export function devStandIn(config) {
|
|
59
|
+
return {
|
|
60
|
+
name: 'dev-stand-in',
|
|
61
|
+
async send(message) {
|
|
62
|
+
await mkdir(config.dir, { recursive: true });
|
|
63
|
+
const file = join(config.dir, `${message.messageId}.json`);
|
|
64
|
+
const { from, to, subject, text, html } = message;
|
|
65
|
+
await writeFile(file, JSON.stringify({ from, to, subject, text, ...(html !== undefined ? { html } : {}) }, null, 2));
|
|
66
|
+
getBaseLogger().info({ event: 'email dev stand-in', to, file }, 'email written to file instead of sent');
|
|
67
|
+
return { providerMessageId: `dev-${message.messageId}` };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=providers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers.js","sourceRoot":"","sources":["../../../src/infra/email/providers.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export declare const EMAIL_TABLE_NAMES: ReadonlySet<string>;
|
|
2
|
+
export declare const EMAIL_STATUSES: readonly ["queued", "sending", "sent", "suppressed", "failed"];
|
|
3
|
+
export declare const SUPPRESSION_REASONS: readonly ["bounce", "complaint"];
|
|
4
|
+
export declare const emailMessages: import("../../data/domain/types.js").DefinedTable<{
|
|
5
|
+
id: import("../../data/domain/types.js").FieldDescriptor<string, {
|
|
6
|
+
defaultRandom: true;
|
|
7
|
+
hasDefault: true;
|
|
8
|
+
} & {
|
|
9
|
+
primaryKey: true;
|
|
10
|
+
}> & import("../../data/domain/field.js").FieldModifiers<string, {
|
|
11
|
+
defaultRandom: true;
|
|
12
|
+
hasDefault: true;
|
|
13
|
+
} & {
|
|
14
|
+
primaryKey: true;
|
|
15
|
+
}>;
|
|
16
|
+
key: import("../../data/domain/types.js").FieldDescriptor<string, {}> & import("../../data/domain/field.js").FieldModifiers<string, {}>;
|
|
17
|
+
toAddress: import("../../data/domain/types.js").FieldDescriptor<string, {}> & import("../../data/domain/field.js").FieldModifiers<string, {}>;
|
|
18
|
+
template: import("../../data/domain/types.js").FieldDescriptor<string, {}> & import("../../data/domain/field.js").FieldModifiers<string, {}>;
|
|
19
|
+
data: import("../../data/domain/types.js").FieldDescriptor<import("zod/v4").JSONType, {}> & import("../../data/domain/field.js").FieldModifiers<import("zod/v4").JSONType, {}>;
|
|
20
|
+
status: import("../../data/domain/types.js").FieldDescriptor<"failed" | "sent" | "queued" | "sending" | "suppressed", {
|
|
21
|
+
hasDefault: true;
|
|
22
|
+
}> & import("../../data/domain/field.js").FieldModifiers<"failed" | "sent" | "queued" | "sending" | "suppressed", {
|
|
23
|
+
hasDefault: true;
|
|
24
|
+
}>;
|
|
25
|
+
providerMessageId: import("../../data/domain/types.js").FieldDescriptor<string | null, {
|
|
26
|
+
nullable: true;
|
|
27
|
+
}> & import("../../data/domain/field.js").FieldModifiers<string | null, {
|
|
28
|
+
nullable: true;
|
|
29
|
+
}>;
|
|
30
|
+
error: import("../../data/domain/types.js").FieldDescriptor<string | null, {
|
|
31
|
+
nullable: true;
|
|
32
|
+
}> & import("../../data/domain/field.js").FieldModifiers<string | null, {
|
|
33
|
+
nullable: true;
|
|
34
|
+
}>;
|
|
35
|
+
traceId: import("../../data/domain/types.js").FieldDescriptor<string | null, {
|
|
36
|
+
nullable: true;
|
|
37
|
+
}> & import("../../data/domain/field.js").FieldModifiers<string | null, {
|
|
38
|
+
nullable: true;
|
|
39
|
+
}>;
|
|
40
|
+
createdAt: import("../../data/domain/types.js").FieldDescriptor<Date, {
|
|
41
|
+
hasDefault: true;
|
|
42
|
+
}> & import("../../data/domain/field.js").FieldModifiers<Date, {
|
|
43
|
+
hasDefault: true;
|
|
44
|
+
}>;
|
|
45
|
+
sentAt: import("../../data/domain/types.js").FieldDescriptor<Date | null, {
|
|
46
|
+
nullable: true;
|
|
47
|
+
}> & import("../../data/domain/field.js").FieldModifiers<Date | null, {
|
|
48
|
+
nullable: true;
|
|
49
|
+
}>;
|
|
50
|
+
sendingAt: import("../../data/domain/types.js").FieldDescriptor<Date | null, {
|
|
51
|
+
nullable: true;
|
|
52
|
+
}> & import("../../data/domain/field.js").FieldModifiers<Date | null, {
|
|
53
|
+
nullable: true;
|
|
54
|
+
}>;
|
|
55
|
+
}, "pipework_email_messages">;
|
|
56
|
+
export declare const emailSuppressions: import("../../data/domain/types.js").DefinedTable<{
|
|
57
|
+
address: import("../../data/domain/types.js").FieldDescriptor<string, {
|
|
58
|
+
primaryKey: true;
|
|
59
|
+
}> & import("../../data/domain/field.js").FieldModifiers<string, {
|
|
60
|
+
primaryKey: true;
|
|
61
|
+
}>;
|
|
62
|
+
reason: import("../../data/domain/types.js").FieldDescriptor<"bounce" | "complaint", {}> & import("../../data/domain/field.js").FieldModifiers<"bounce" | "complaint", {}>;
|
|
63
|
+
detail: import("../../data/domain/types.js").FieldDescriptor<import("zod/v4").JSONType, {
|
|
64
|
+
nullable: true;
|
|
65
|
+
}> & import("../../data/domain/field.js").FieldModifiers<import("zod/v4").JSONType, {
|
|
66
|
+
nullable: true;
|
|
67
|
+
}>;
|
|
68
|
+
createdAt: import("../../data/domain/types.js").FieldDescriptor<Date, {
|
|
69
|
+
hasDefault: true;
|
|
70
|
+
}> & import("../../data/domain/field.js").FieldModifiers<Date, {
|
|
71
|
+
hasDefault: true;
|
|
72
|
+
}>;
|
|
73
|
+
}, "pipework_email_suppressions">;
|
|
74
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/infra/email/schema.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAEhD,CAAA;AAEF,eAAO,MAAM,cAAc,gEAAiE,CAAA;AAC5F,eAAO,MAAM,mBAAmB,kCAAmC,CAAA;AAEnE,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;6BAkBxB,CAAA;AAEF,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;iCAK5B,CAAA"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The two tables behind `mail`. Enabled with `email: {}` in the blueprint;
|
|
2
|
+
// `pipework generate` then emits them into the default database's migrations,
|
|
3
|
+
// IF NOT EXISTS. Not tenant-scoped: a bounce notification arrives with no
|
|
4
|
+
// tenant, and a suppressed address is suppressed for every tenant.
|
|
5
|
+
import { define } from '../../data/domain/index.js';
|
|
6
|
+
import { field } from '../../data/domain/field.js';
|
|
7
|
+
import { schema } from '../../data/schema/namespace.js';
|
|
8
|
+
import { index, unique } from '../../data/db/idx/index.js';
|
|
9
|
+
export const EMAIL_TABLE_NAMES = new Set([
|
|
10
|
+
'pipework_email_messages', 'pipework_email_suppressions',
|
|
11
|
+
]);
|
|
12
|
+
export const EMAIL_STATUSES = ['queued', 'sending', 'sent', 'suppressed', 'failed'];
|
|
13
|
+
export const SUPPRESSION_REASONS = ['bounce', 'complaint'];
|
|
14
|
+
export const emailMessages = define('pipework_email_messages', {
|
|
15
|
+
id: field.uuid().defaultRandom().primaryKey(),
|
|
16
|
+
key: field.text(),
|
|
17
|
+
toAddress: field.text(),
|
|
18
|
+
template: field.text(),
|
|
19
|
+
data: field.jsonb(schema.check.json()),
|
|
20
|
+
status: field.enum(EMAIL_STATUSES).default('queued'),
|
|
21
|
+
providerMessageId: field.text().nullable(),
|
|
22
|
+
error: field.text().nullable(),
|
|
23
|
+
traceId: field.uuid().nullable(),
|
|
24
|
+
createdAt: field.timestamp().default('now'),
|
|
25
|
+
sentAt: field.timestamp().nullable(),
|
|
26
|
+
sendingAt: field.timestamp().nullable(),
|
|
27
|
+
}, {
|
|
28
|
+
indexes: (t) => ({
|
|
29
|
+
byKey: unique('pipework_email_messages_key_key').on(t['key']),
|
|
30
|
+
byProviderId: index('idx_pipework_email_messages_provider_id').on(t['providerMessageId']),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
export const emailSuppressions = define('pipework_email_suppressions', {
|
|
34
|
+
address: field.text().primaryKey(),
|
|
35
|
+
reason: field.enum(SUPPRESSION_REASONS),
|
|
36
|
+
detail: field.jsonb(schema.check.json()).nullable(),
|
|
37
|
+
createdAt: field.timestamp().default('now'),
|
|
38
|
+
});
|
|
39
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/infra/email/schema.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,8EAA8E;AAC9E,0EAA0E;AAC1E,mEAAmE;AAEnE,OAAO,EAAE,MAAM,EAAE,MAAM,4BAA4B,CAAA;AACnD,OAAO,EAAE,KAAK,EAAE,MAAM,4BAA4B,CAAA;AAClD,OAAO,EAAE,MAAM,EAAE,MAAM,gCAAgC,CAAA;AACvD,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,4BAA4B,CAAA;AAE1D,MAAM,CAAC,MAAM,iBAAiB,GAAwB,IAAI,GAAG,CAAC;IAC5D,yBAAyB,EAAE,6BAA6B;CACzD,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,QAAQ,CAAU,CAAA;AAC5F,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,QAAQ,EAAE,WAAW,CAAU,CAAA;AAEnE,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC,yBAAyB,EAAE;IAC7D,EAAE,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,aAAa,EAAE,CAAC,UAAU,EAAE;IAC7C,GAAG,EAAE,KAAK,CAAC,IAAI,EAAE;IACjB,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE;IACvB,QAAQ,EAAE,KAAK,CAAC,IAAI,EAAE;IACtB,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IACtC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC;IACpD,iBAAiB,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE;IAC1C,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE;IAC9B,OAAO,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,QAAQ,EAAE;IAChC,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC;IAC3C,MAAM,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,QAAQ,EAAE;IACpC,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,QAAQ,EAAE;CACxC,EAAE;IACD,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACf,KAAK,EAAE,MAAM,CAAC,iCAAiC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;QAC7D,YAAY,EAAE,KAAK,CAAC,yCAAyC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC;KAC1F,CAAC;CACH,CAAC,CAAA;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC,6BAA6B,EAAE;IACrE,OAAO,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,UAAU,EAAE;IAClC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,mBAAmB,CAAC;IACvC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,EAAE;IACnD,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC;CAC5C,CAAC,CAAA"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface AwsCredentials {
|
|
2
|
+
readonly accessKeyId: string;
|
|
3
|
+
readonly secretAccessKey: string;
|
|
4
|
+
readonly sessionToken?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SigV4Request {
|
|
7
|
+
readonly method: string;
|
|
8
|
+
readonly url: string;
|
|
9
|
+
/** Headers to sign and send, other than host and x-amz-date, which are added. */
|
|
10
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
11
|
+
readonly body: string | Buffer;
|
|
12
|
+
readonly region: string;
|
|
13
|
+
readonly service: string;
|
|
14
|
+
readonly credentials: AwsCredentials;
|
|
15
|
+
readonly now?: Date;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Signs a request with AWS Signature Version 4 and returns the headers to send:
|
|
19
|
+
* the given ones, `x-amz-date`, `x-amz-security-token` when the credentials carry
|
|
20
|
+
* a session token, and `authorization`. `host` is signed but not returned; the
|
|
21
|
+
* HTTP client sets it from the URL.
|
|
22
|
+
*/
|
|
23
|
+
export declare function signV4(req: SigV4Request): Record<string, string>;
|
|
24
|
+
//# sourceMappingURL=sigv4.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sigv4.d.ts","sourceRoot":"","sources":["../../../src/infra/email/sigv4.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAChC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,EAAE,cAAc,CAAA;IACpC,QAAQ,CAAC,GAAG,CAAC,EAAE,IAAI,CAAA;CACpB;AAkBD;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,GAAG,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAwChE"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
function sha256Hex(data) {
|
|
3
|
+
return createHash('sha256').update(data).digest('hex');
|
|
4
|
+
}
|
|
5
|
+
function hmac(key, data) {
|
|
6
|
+
return createHmac('sha256', key).update(data).digest();
|
|
7
|
+
}
|
|
8
|
+
function uriEncode(s) {
|
|
9
|
+
return encodeURIComponent(s).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
10
|
+
}
|
|
11
|
+
function amzDate(d) {
|
|
12
|
+
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Signs a request with AWS Signature Version 4 and returns the headers to send:
|
|
16
|
+
* the given ones, `x-amz-date`, `x-amz-security-token` when the credentials carry
|
|
17
|
+
* a session token, and `authorization`. `host` is signed but not returned; the
|
|
18
|
+
* HTTP client sets it from the URL.
|
|
19
|
+
*/
|
|
20
|
+
export function signV4(req) {
|
|
21
|
+
const url = new URL(req.url);
|
|
22
|
+
const stamp = amzDate(req.now ?? new Date());
|
|
23
|
+
const day = stamp.slice(0, 8);
|
|
24
|
+
const headers = {};
|
|
25
|
+
for (const [name, value] of Object.entries(req.headers))
|
|
26
|
+
headers[name.toLowerCase()] = value.trim().replace(/\s+/g, ' ');
|
|
27
|
+
headers['x-amz-date'] = stamp;
|
|
28
|
+
if (req.credentials.sessionToken !== undefined)
|
|
29
|
+
headers['x-amz-security-token'] = req.credentials.sessionToken;
|
|
30
|
+
const signed = { ...headers, host: url.host };
|
|
31
|
+
const names = Object.keys(signed).sort();
|
|
32
|
+
const canonicalHeaders = names.map(n => `${n}:${signed[n]}\n`).join('');
|
|
33
|
+
const signedHeaders = names.join(';');
|
|
34
|
+
const canonicalPath = url.pathname.split('/').map(uriEncode).join('/');
|
|
35
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
36
|
+
.map(([k, v]) => [uriEncode(k), uriEncode(v)])
|
|
37
|
+
.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : (a[0] < b[0] ? -1 : 1)))
|
|
38
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
39
|
+
.join('&');
|
|
40
|
+
const canonicalRequest = [
|
|
41
|
+
req.method.toUpperCase(), canonicalPath, canonicalQuery, canonicalHeaders, signedHeaders, sha256Hex(req.body),
|
|
42
|
+
].join('\n');
|
|
43
|
+
const scope = `${day}/${req.region}/${req.service}/aws4_request`;
|
|
44
|
+
const stringToSign = ['AWS4-HMAC-SHA256', stamp, scope, sha256Hex(canonicalRequest)].join('\n');
|
|
45
|
+
const kDate = hmac(`AWS4${req.credentials.secretAccessKey}`, day);
|
|
46
|
+
const kRegion = hmac(kDate, req.region);
|
|
47
|
+
const kService = hmac(kRegion, req.service);
|
|
48
|
+
const kSigning = hmac(kService, 'aws4_request');
|
|
49
|
+
const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
|
50
|
+
return {
|
|
51
|
+
...headers,
|
|
52
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${req.credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=sigv4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sigv4.js","sourceRoot":"","sources":["../../../src/infra/email/sigv4.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Handler } from '../../request/di/types.js';
|
|
2
|
+
export interface SnsWebhookConfig {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
/** The SNS topics whose messages are accepted. SNS signs every topic, so without this anyone could subscribe the endpoint to their own topic. */
|
|
5
|
+
readonly topicArns: readonly string[];
|
|
6
|
+
/** The SNS region; certificate and SubscribeURL hosts must be `sns.<region>.amazonaws.com`. Default: any AWS region. */
|
|
7
|
+
readonly region?: string;
|
|
8
|
+
/** Fetches the signing certificate PEM. Default: `fetch`. Tests serve their own. */
|
|
9
|
+
readonly fetchCert?: (url: string) => Promise<string>;
|
|
10
|
+
/** Confirms a subscription by GET of its SubscribeURL. Default: `fetch`. */
|
|
11
|
+
readonly confirm?: (url: string) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
type SnsMessage = Record<string, string | undefined>;
|
|
14
|
+
export declare function snsStringToSign(msg: SnsMessage): string;
|
|
15
|
+
/**
|
|
16
|
+
* Receives SES bounce and complaint events delivered through SNS (HTTP/S subscription).
|
|
17
|
+
* A permanent bounce or any complaint puts the address (lowercased) on
|
|
18
|
+
* `pipework_email_suppressions`, which the send job checks; a transient or undetermined
|
|
19
|
+
* bounce is only logged. Each one is a trace step with an `Email:bounce` / `Email:complaint`
|
|
20
|
+
* audit record whose `after` carries `messageTraceId`, the trace of the original send.
|
|
21
|
+
* A suppression is logged at warn as `email suppressed`; the alert condition (#458) is not built yet.
|
|
22
|
+
*
|
|
23
|
+
* Only messages from the configured `topicArns` are accepted (401 otherwise, before any subscription is
|
|
24
|
+
* confirmed or event processed): SNS signs every topic, including an attacker's own.
|
|
25
|
+
* Every message's signature (SignatureVersion 1 or 2) is checked against the certificate
|
|
26
|
+
* at `SigningCertURL`, which must be `https://sns.<region>.amazonaws.com/...`; a failure is a 401.
|
|
27
|
+
* A SubscriptionConfirmation is confirmed by fetching its `SubscribeURL`.
|
|
28
|
+
* SES events delivered through EventBridge are not supported.
|
|
29
|
+
*/
|
|
30
|
+
export declare function snsWebhook(config: SnsWebhookConfig): Handler<Record<string, unknown>, unknown>;
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=sns.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sns.d.ts","sourceRoot":"","sources":["../../../src/infra/email/sns.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAKxD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,iJAAiJ;IACjJ,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,wHAAwH;IACxH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,oFAAoF;IACpF,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IACrD,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAClD;AAED,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;AAKpD,wBAAgB,eAAe,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAGvD;AAqED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,CA2D9F"}
|