@q32/core 0.1.27 → 0.1.29
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/README.md +4 -0
- package/dist/aws.d.ts +24 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +109 -0
- package/dist/aws.js.map +1 -0
- package/dist/email.d.ts +8 -0
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +87 -0
- package/dist/email.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ses-sns.d.ts +60 -0
- package/dist/ses-sns.d.ts.map +1 -0
- package/dist/ses-sns.js +96 -0
- package/dist/ses-sns.js.map +1 -0
- package/dist/ses.d.ts +53 -0
- package/dist/ses.d.ts.map +1 -0
- package/dist/ses.js +125 -0
- package/dist/ses.js.map +1 -0
- package/docs/ses-sns-helper-survey.md +235 -0
- package/package.json +13 -1
- package/src/aws.ts +152 -0
- package/src/email.ts +99 -0
- package/src/index.ts +3 -1
- package/src/ses-sns.ts +144 -0
- package/src/ses.ts +180 -0
package/src/ses-sns.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type SnsEnvelope = {
|
|
2
|
+
Type?: string;
|
|
3
|
+
Message?: string;
|
|
4
|
+
MessageId?: string;
|
|
5
|
+
TopicArn?: string;
|
|
6
|
+
SubscribeURL?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SesFeedbackMessage = {
|
|
10
|
+
eventType?: string;
|
|
11
|
+
notificationType?: string;
|
|
12
|
+
mail?: {
|
|
13
|
+
destination?: unknown;
|
|
14
|
+
};
|
|
15
|
+
bounce?: {
|
|
16
|
+
bouncedRecipients?: Array<{ emailAddress?: unknown }>;
|
|
17
|
+
};
|
|
18
|
+
complaint?: {
|
|
19
|
+
complainedRecipients?: Array<{ emailAddress?: unknown }>;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SesFeedbackEvent = {
|
|
24
|
+
eventType: string;
|
|
25
|
+
emails: string[];
|
|
26
|
+
messageId?: string;
|
|
27
|
+
topicArn?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SesReceiptMessage = {
|
|
31
|
+
mail?: {
|
|
32
|
+
messageId?: string;
|
|
33
|
+
source?: string;
|
|
34
|
+
commonHeaders?: {
|
|
35
|
+
subject?: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
receipt?: {
|
|
39
|
+
recipients?: string[];
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type SesReceiptEvent = {
|
|
44
|
+
messageId: string;
|
|
45
|
+
source?: string;
|
|
46
|
+
subject?: string;
|
|
47
|
+
recipients: string[];
|
|
48
|
+
snsMessageId?: string;
|
|
49
|
+
topicArn?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function parseSnsEnvelope(body: string): SnsEnvelope | null {
|
|
53
|
+
const parsed = parseJsonObject(body);
|
|
54
|
+
if (!parsed) return null;
|
|
55
|
+
return parsed as SnsEnvelope;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isTrustedSnsSubscribeUrl(raw: string): boolean {
|
|
59
|
+
let url: URL;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
url.protocol === "https:" &&
|
|
67
|
+
(/^sns[.-][a-z0-9-]+\.amazonaws\.com$/i.test(url.hostname) ||
|
|
68
|
+
/^sns\.[a-z0-9-]+\.amazonaws\.com\.cn$/i.test(url.hostname))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function confirmSnsSubscription(envelope: SnsEnvelope, fetcher: typeof fetch = fetch): Promise<boolean> {
|
|
73
|
+
if (envelope.Type !== "SubscriptionConfirmation" || typeof envelope.SubscribeURL !== "string") return false;
|
|
74
|
+
if (!isTrustedSnsSubscribeUrl(envelope.SubscribeURL)) return false;
|
|
75
|
+
const response = await fetcher(envelope.SubscribeURL, { method: "GET" });
|
|
76
|
+
return response.ok;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseSesFeedbackNotification(envelope: SnsEnvelope): SesFeedbackEvent | null {
|
|
80
|
+
if (envelope.Type !== "Notification" || typeof envelope.Message !== "string") return null;
|
|
81
|
+
const message = parseJsonObject(envelope.Message) as SesFeedbackMessage | null;
|
|
82
|
+
if (!message) return null;
|
|
83
|
+
const { eventType, emails } = extractSesFeedbackRecipients(message);
|
|
84
|
+
if (!eventType) return null;
|
|
85
|
+
return {
|
|
86
|
+
eventType,
|
|
87
|
+
emails,
|
|
88
|
+
...(envelope.MessageId ? { messageId: envelope.MessageId } : {}),
|
|
89
|
+
...(envelope.TopicArn ? { topicArn: envelope.TopicArn } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function extractSesFeedbackRecipients(message: SesFeedbackMessage): { eventType: string; emails: string[] } {
|
|
94
|
+
const eventType = String(message.eventType ?? message.notificationType ?? "");
|
|
95
|
+
const normalized = eventType.toLowerCase();
|
|
96
|
+
if (normalized === "bounce") {
|
|
97
|
+
const recipients = message.bounce?.bouncedRecipients?.map((recipient) => recipient.emailAddress) ?? [];
|
|
98
|
+
return { eventType, emails: uniqueEmails(recipients.length > 0 ? recipients : fallbackDestination(message)) };
|
|
99
|
+
}
|
|
100
|
+
if (normalized === "complaint") {
|
|
101
|
+
const recipients = message.complaint?.complainedRecipients?.map((recipient) => recipient.emailAddress) ?? [];
|
|
102
|
+
return { eventType, emails: uniqueEmails(recipients.length > 0 ? recipients : fallbackDestination(message)) };
|
|
103
|
+
}
|
|
104
|
+
return { eventType, emails: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseSesReceiptNotification(envelope: SnsEnvelope): SesReceiptEvent | null {
|
|
108
|
+
if (envelope.Type !== "Notification" || typeof envelope.Message !== "string") return null;
|
|
109
|
+
const message = parseJsonObject(envelope.Message) as SesReceiptMessage | null;
|
|
110
|
+
const messageId = message?.mail?.messageId;
|
|
111
|
+
const recipients = message?.receipt?.recipients ?? [];
|
|
112
|
+
if (!messageId || recipients.length === 0) return null;
|
|
113
|
+
return {
|
|
114
|
+
messageId,
|
|
115
|
+
recipients,
|
|
116
|
+
...(message.mail?.source ? { source: message.mail.source } : {}),
|
|
117
|
+
...(message.mail?.commonHeaders?.subject ? { subject: message.mail.commonHeaders.subject } : {}),
|
|
118
|
+
...(envelope.MessageId ? { snsMessageId: envelope.MessageId } : {}),
|
|
119
|
+
...(envelope.TopicArn ? { topicArn: envelope.TopicArn } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseJsonObject(value: string): Record<string, unknown> | null {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(value);
|
|
126
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function fallbackDestination(message: SesFeedbackMessage): unknown[] {
|
|
133
|
+
return Array.isArray(message.mail?.destination) ? message.mail.destination : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function uniqueEmails(values: Iterable<unknown>): string[] {
|
|
137
|
+
const emails = new Set<string>();
|
|
138
|
+
for (const value of values) {
|
|
139
|
+
if (typeof value !== "string") continue;
|
|
140
|
+
const email = value.trim().toLowerCase();
|
|
141
|
+
if (email.includes("@")) emails.add(email);
|
|
142
|
+
}
|
|
143
|
+
return [...emails];
|
|
144
|
+
}
|
package/src/ses.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { awsFetch, createAwsConfigFromEnv, type AwsConfig, type AwsEnv } from "./aws.js";
|
|
2
|
+
import { buildRawMimeMessage, formatEmailAddress, type EmailAddress, type SendEmailInput } from "./email.js";
|
|
3
|
+
|
|
4
|
+
export type SesEmailInput = Omit<SendEmailInput, "from"> & {
|
|
5
|
+
from?: EmailAddress;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SesSendResult = {
|
|
10
|
+
messageId: string;
|
|
11
|
+
provider: "ses";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SesMailer = {
|
|
15
|
+
readonly enabled: boolean;
|
|
16
|
+
send(input: SesEmailInput): Promise<SesSendResult>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type SesMailerEnv = AwsEnv & {
|
|
20
|
+
FROM_EMAIL?: string;
|
|
21
|
+
EMAIL_FROM?: string;
|
|
22
|
+
NOTIFICATION_FROM_EMAIL?: string;
|
|
23
|
+
POSTMARK_FROM_EMAIL?: string;
|
|
24
|
+
SES_FROM_EMAIL?: string;
|
|
25
|
+
EMAIL_REPLY_TO?: string;
|
|
26
|
+
NOTIFICATION_REPLY_TO?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SesSendOptions = {
|
|
30
|
+
fetcher?: typeof fetch;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class NoopSesMailer implements SesMailer {
|
|
34
|
+
readonly enabled = false;
|
|
35
|
+
|
|
36
|
+
async send(): Promise<SesSendResult> {
|
|
37
|
+
throw new Error("SES mailer is not configured.");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class WorkerSesMailer implements SesMailer {
|
|
42
|
+
readonly enabled = true;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly config: AwsConfig,
|
|
46
|
+
private readonly defaults: {
|
|
47
|
+
from: EmailAddress;
|
|
48
|
+
replyTo?: EmailAddress[];
|
|
49
|
+
fetcher?: typeof fetch;
|
|
50
|
+
},
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
send(input: SesEmailInput): Promise<SesSendResult> {
|
|
54
|
+
return sendSesEmail(this.config, {
|
|
55
|
+
...input,
|
|
56
|
+
from: input.from ?? this.defaults.from,
|
|
57
|
+
replyTo: input.replyTo ?? this.defaults.replyTo,
|
|
58
|
+
}, { fetcher: this.defaults.fetcher });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createSesMailerFromEnv(
|
|
63
|
+
env: SesMailerEnv,
|
|
64
|
+
options: {
|
|
65
|
+
fromEmail?: string;
|
|
66
|
+
fromName?: string;
|
|
67
|
+
replyTo?: string | string[] | null;
|
|
68
|
+
fetcher?: typeof fetch;
|
|
69
|
+
} = {},
|
|
70
|
+
): SesMailer {
|
|
71
|
+
const config = createAwsConfigFromEnv(env);
|
|
72
|
+
const fromEmail = options.fromEmail ?? env.FROM_EMAIL ?? env.EMAIL_FROM ?? env.NOTIFICATION_FROM_EMAIL ?? env.POSTMARK_FROM_EMAIL ?? env.SES_FROM_EMAIL;
|
|
73
|
+
if (!config || !fromEmail) return new NoopSesMailer();
|
|
74
|
+
const replyTo = options.replyTo ?? env.NOTIFICATION_REPLY_TO ?? env.EMAIL_REPLY_TO;
|
|
75
|
+
return new WorkerSesMailer(config, {
|
|
76
|
+
from: { email: fromEmail, name: options.fromName },
|
|
77
|
+
replyTo: typeof replyTo === "string"
|
|
78
|
+
? [{ email: replyTo }]
|
|
79
|
+
: Array.isArray(replyTo)
|
|
80
|
+
? replyTo.map((email) => ({ email }))
|
|
81
|
+
: undefined,
|
|
82
|
+
fetcher: options.fetcher,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function sendSesEmail(
|
|
87
|
+
config: AwsConfig,
|
|
88
|
+
input: SendEmailInput & { headers?: Record<string, string> },
|
|
89
|
+
options: SesSendOptions = {},
|
|
90
|
+
): Promise<SesSendResult> {
|
|
91
|
+
const hasRawFeatures = Boolean(input.headers && Object.keys(input.headers).length > 0) || Boolean(input.attachments?.length);
|
|
92
|
+
const body = hasRawFeatures ? rawSesBody(input) : simpleSesBody(input);
|
|
93
|
+
const response = await awsFetch(config, `https://email.${config.region}.amazonaws.com/v2/email/outbound-emails`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
}, { service: "ses", fetcher: options.fetcher });
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
if (!response.ok) throw new Error(`SES send failed: ${response.status} ${text.slice(0, 300)}`);
|
|
100
|
+
const payload = parseJsonObject(text);
|
|
101
|
+
const messageId = stringValue(payload.MessageId) ?? stringValue(payload.messageId) ?? "";
|
|
102
|
+
return { provider: "ses", messageId };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function sesQuery(
|
|
106
|
+
config: AwsConfig,
|
|
107
|
+
params: Record<string, string>,
|
|
108
|
+
options: SesSendOptions = {},
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const body = new URLSearchParams({ Version: "2010-12-01", ...params }).toString();
|
|
111
|
+
const response = await awsFetch(config, `https://email.${config.region}.amazonaws.com/`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
114
|
+
body,
|
|
115
|
+
}, { service: "ses", fetcher: options.fetcher });
|
|
116
|
+
const text = await response.text();
|
|
117
|
+
if (!response.ok) throw new Error(`SES ${params.Action ?? "query"} failed: ${response.status} ${text.slice(0, 300)}`);
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function inboundSesMxHost(region = "us-east-1"): string {
|
|
122
|
+
return `inbound-smtp.${region}.amazonaws.com`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function simpleSesBody(input: SendEmailInput): Record<string, unknown> {
|
|
126
|
+
return {
|
|
127
|
+
FromEmailAddress: formatEmailAddress(input.from),
|
|
128
|
+
Destination: {
|
|
129
|
+
ToAddresses: input.to.map(formatEmailAddress),
|
|
130
|
+
...(input.cc?.length ? { CcAddresses: input.cc.map(formatEmailAddress) } : {}),
|
|
131
|
+
...(input.bcc?.length ? { BccAddresses: input.bcc.map(formatEmailAddress) } : {}),
|
|
132
|
+
},
|
|
133
|
+
...(input.replyTo?.length ? { ReplyToAddresses: input.replyTo.map(formatEmailAddress) } : {}),
|
|
134
|
+
Content: {
|
|
135
|
+
Simple: {
|
|
136
|
+
Subject: { Data: input.subject, Charset: "UTF-8" },
|
|
137
|
+
Body: {
|
|
138
|
+
...(input.text ? { Text: { Data: input.text, Charset: "UTF-8" } } : {}),
|
|
139
|
+
...(input.html ? { Html: { Data: input.html, Charset: "UTF-8" } } : {}),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function rawSesBody(input: SendEmailInput & { headers?: Record<string, string> }): Record<string, unknown> {
|
|
147
|
+
return {
|
|
148
|
+
FromEmailAddress: formatEmailAddress(input.from),
|
|
149
|
+
Destination: {
|
|
150
|
+
ToAddresses: input.to.map(formatEmailAddress),
|
|
151
|
+
...(input.cc?.length ? { CcAddresses: input.cc.map(formatEmailAddress) } : {}),
|
|
152
|
+
...(input.bcc?.length ? { BccAddresses: input.bcc.map(formatEmailAddress) } : {}),
|
|
153
|
+
},
|
|
154
|
+
Content: {
|
|
155
|
+
Raw: {
|
|
156
|
+
Data: textToBase64(buildRawMimeMessage(input)),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function textToBase64(value: string): string {
|
|
163
|
+
const bytes = new TextEncoder().encode(value);
|
|
164
|
+
let binary = "";
|
|
165
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
166
|
+
return btoa(binary);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseJsonObject(value: string): Record<string, unknown> {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(value);
|
|
172
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
173
|
+
} catch {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stringValue(value: unknown): string | null {
|
|
179
|
+
return typeof value === "string" ? value : null;
|
|
180
|
+
}
|