@upyo/logtape 0.6.0-dev.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/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright 2025 Hong Minhee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ <!-- deno-fmt-ignore-file -->
2
+
3
+ @upyo/logtape
4
+ =============
5
+
6
+ *@upyo/logtape* records Upyo email delivery lifecycle events through
7
+ [LogTape]. It can act as a log-only transport during local development or
8
+ decorate another transport while preserving its delivery behavior.
9
+
10
+ [LogTape]: https://logtape.org/
11
+
12
+
13
+ Installation
14
+ ------------
15
+
16
+ ~~~~ bash
17
+ deno add jsr:@upyo/logtape jsr:@logtape/logtape
18
+ pnpm add @upyo/logtape @logtape/logtape
19
+ ~~~~
20
+
21
+
22
+ Usage
23
+ -----
24
+
25
+ Configure LogTape in your application, then create a log-only transport:
26
+
27
+ ~~~~ typescript
28
+ import { configure, getConsoleSink } from "@logtape/logtape";
29
+ import { LogTapeTransport } from "@upyo/logtape";
30
+
31
+ await configure({
32
+ sinks: { console: getConsoleSink() },
33
+ loggers: [
34
+ { category: ["upyo"], lowestLevel: "debug", sinks: ["console"] },
35
+ ],
36
+ });
37
+
38
+ const transport = new LogTapeTransport();
39
+ const receipt = await transport.send(message);
40
+ ~~~~
41
+
42
+ To deliver messages through another transport, pass it in the options object:
43
+
44
+ ~~~~ typescript
45
+ const transport = new LogTapeTransport({
46
+ transport: smtpTransport,
47
+ category: ["application", "email"],
48
+ levels: {
49
+ sending: "debug",
50
+ sent: "info",
51
+ failed: "error",
52
+ },
53
+ });
54
+ ~~~~
55
+
56
+ Complete messages are excluded from structured logs by default. Set
57
+ `recordMessage: true` only when the configured sinks and redaction rules can
58
+ safely handle addresses, bodies, headers, and attachment data.
package/dist/index.cjs ADDED
@@ -0,0 +1,329 @@
1
+ //#region rolldown:runtime
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
15
+ }
16
+ return to;
17
+ };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ value: mod,
20
+ enumerable: true
21
+ }) : target, mod));
22
+
23
+ //#endregion
24
+ const __logtape_logtape = __toESM(require("@logtape/logtape"));
25
+
26
+ //#region src/config.ts
27
+ /**
28
+ * Resolves LogTape transport options with their defaults.
29
+ *
30
+ * @param options User-provided transport options.
31
+ * @returns Fully resolved transport options.
32
+ * @since 0.6.0
33
+ */
34
+ function resolveLogTapeTransportOptions(options) {
35
+ const category = options.category ?? ["upyo"];
36
+ return {
37
+ transport: options.transport,
38
+ category: typeof category === "string" ? [category] : [...category],
39
+ recordMessage: options.recordMessage ?? false,
40
+ levels: {
41
+ sending: options.levels?.sending ?? "debug",
42
+ sent: options.levels?.sent ?? "info",
43
+ failed: options.levels?.failed ?? "error"
44
+ }
45
+ };
46
+ }
47
+
48
+ //#endregion
49
+ //#region src/pending-queue.ts
50
+ /**
51
+ * Internal linked FIFO queue for messages awaiting delivery receipts.
52
+ *
53
+ * Both enqueue and dequeue operations take constant time, without retaining
54
+ * references to values that have already been dequeued.
55
+ *
56
+ * @typeParam T The queued value type.
57
+ * @since 0.6.0
58
+ */
59
+ var PendingQueue = class {
60
+ head;
61
+ tail;
62
+ valueCount = 0;
63
+ /** Number of values waiting in the queue. */
64
+ get size() {
65
+ return this.valueCount;
66
+ }
67
+ /**
68
+ * Adds a value to the end of the queue.
69
+ *
70
+ * @param value The value to enqueue.
71
+ */
72
+ enqueue(value) {
73
+ const node = { value };
74
+ if (this.tail == null) this.head = node;
75
+ else this.tail.next = node;
76
+ this.tail = node;
77
+ this.valueCount++;
78
+ }
79
+ /**
80
+ * Removes and returns the value at the front of the queue.
81
+ *
82
+ * @returns The first queued value, or `undefined` when the queue is empty.
83
+ */
84
+ dequeue() {
85
+ const node = this.head;
86
+ if (node == null) return void 0;
87
+ this.head = node.next;
88
+ node.next = void 0;
89
+ this.valueCount--;
90
+ if (this.head == null) this.tail = void 0;
91
+ return node.value;
92
+ }
93
+ /** Iterates over queued values without removing them. */
94
+ *[Symbol.iterator]() {
95
+ let node = this.head;
96
+ while (node != null) {
97
+ yield node.value;
98
+ node = node.next;
99
+ }
100
+ }
101
+ };
102
+
103
+ //#endregion
104
+ //#region src/logtape-transport.ts
105
+ /**
106
+ * Transport that records email delivery lifecycle events through LogTape.
107
+ *
108
+ * With no wrapped transport, this class acts as a log-only transport and
109
+ * returns synthetic successful receipts. When a transport is supplied, it
110
+ * decorates that transport without changing its receipts or thrown errors.
111
+ *
112
+ * @typeParam TProviderId The provider id of the wrapped transport, or
113
+ * `"logtape"` in log-only mode.
114
+ * @since 0.6.0
115
+ */
116
+ var LogTapeTransport = class {
117
+ /** Provider id used by receipts from this transport. */
118
+ id;
119
+ /** Fully resolved transport options. */
120
+ config;
121
+ logger;
122
+ wrappedTransport;
123
+ /**
124
+ * Creates a LogTape transport.
125
+ *
126
+ * @param options Logging and optional wrapped transport configuration.
127
+ */
128
+ constructor(...[options]) {
129
+ this.config = resolveLogTapeTransportOptions(options ?? {});
130
+ this.wrappedTransport = this.config.transport;
131
+ this.id = this.wrappedTransport?.id ?? "logtape";
132
+ this.logger = (0, __logtape_logtape.getLogger)(this.config.category);
133
+ }
134
+ /**
135
+ * Sends one email while recording its delivery lifecycle.
136
+ *
137
+ * @param message The email message to send.
138
+ * @param options Optional transport options, including cancellation.
139
+ * @returns The wrapped transport receipt, or a synthetic successful receipt
140
+ * in log-only mode.
141
+ * @throws {DOMException} If the operation is aborted.
142
+ * @throws {Error} If the wrapped transport throws an error.
143
+ */
144
+ send(message, options) {
145
+ return this.sendOne(message, options, "send");
146
+ }
147
+ /**
148
+ * Sends multiple emails while recording each delivery lifecycle.
149
+ *
150
+ * A wrapped transport's `sendMany()` implementation is used directly so
151
+ * provider-specific batching and streaming behavior are preserved.
152
+ *
153
+ * @param messages Email messages to send.
154
+ * @param options Optional transport options, including cancellation.
155
+ * @returns An async iterable of unmodified delivery receipts.
156
+ * @throws {DOMException} If the operation is aborted.
157
+ * @throws {Error} If the wrapped transport throws an error.
158
+ */
159
+ async *sendMany(messages, options) {
160
+ options?.signal?.throwIfAborted();
161
+ if (this.wrappedTransport == null) {
162
+ for await (const message of messages) yield await this.sendOne(message, options, "sendMany");
163
+ return;
164
+ }
165
+ const pending = new PendingQueue();
166
+ const batchStartedAt = performance.now();
167
+ let consumedCount = 0;
168
+ let completedCount = 0;
169
+ const observedMessages = this.observeMessages(messages, pending, options, () => consumedCount++);
170
+ try {
171
+ for await (const receipt of this.wrappedTransport.sendMany(observedMessages, options)) {
172
+ const current = pending.dequeue();
173
+ completedCount++;
174
+ this.logReceipt(receipt, current, "sendMany");
175
+ yield receipt;
176
+ }
177
+ } catch (error) {
178
+ const batchProperties = {
179
+ consumedCount,
180
+ completedCount,
181
+ pendingCount: pending.size
182
+ };
183
+ if (pending.size < 1) this.logThrownError(error, void 0, "sendMany", batchStartedAt, batchProperties);
184
+ else for (const pendingMessage of pending) this.logThrownError(error, pendingMessage.message, "sendMany", pendingMessage.startedAt, batchProperties);
185
+ throw error;
186
+ }
187
+ }
188
+ /**
189
+ * Disposes the wrapped transport when it supports explicit resource
190
+ * management.
191
+ */
192
+ async [Symbol.asyncDispose]() {
193
+ const transport = this.wrappedTransport;
194
+ if (transport == null) return;
195
+ if (isAsyncDisposable(transport)) {
196
+ await transport[Symbol.asyncDispose]();
197
+ return;
198
+ }
199
+ if (isDisposable(transport)) transport[Symbol.dispose]();
200
+ }
201
+ async sendOne(message, options, operation) {
202
+ options?.signal?.throwIfAborted();
203
+ const startedAt = performance.now();
204
+ this.logSending(message, operation);
205
+ try {
206
+ const receipt = this.wrappedTransport == null ? this.createSyntheticReceipt() : await this.wrappedTransport.send(message, options);
207
+ this.logReceipt(receipt, {
208
+ message,
209
+ startedAt
210
+ }, operation);
211
+ return receipt;
212
+ } catch (error) {
213
+ this.logThrownError(error, message, operation, startedAt);
214
+ throw error;
215
+ }
216
+ }
217
+ async *observeMessages(messages, pending, options, onConsume) {
218
+ for await (const message of messages) {
219
+ options?.signal?.throwIfAborted();
220
+ const startedAt = performance.now();
221
+ this.logSending(message, "sendMany");
222
+ pending.enqueue({
223
+ message,
224
+ startedAt
225
+ });
226
+ onConsume();
227
+ yield message;
228
+ }
229
+ }
230
+ createSyntheticReceipt() {
231
+ return {
232
+ successful: true,
233
+ messageId: `logtape-${crypto.randomUUID()}`,
234
+ provider: this.id,
235
+ attempts: 1,
236
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
237
+ };
238
+ }
239
+ logSending(message, operation) {
240
+ this.log(this.config.levels.sending, "Sending email.", {
241
+ ...this.getMessageProperties(message),
242
+ event: "email.sending",
243
+ operation,
244
+ transportId: this.id
245
+ });
246
+ }
247
+ logReceipt(receipt, pending, operation) {
248
+ const durationMilliseconds = pending == null ? void 0 : performance.now() - pending.startedAt;
249
+ const messageProperties = pending == null ? {} : this.getMessageProperties(pending.message);
250
+ if (receipt.successful) {
251
+ this.log(this.config.levels.sent, "Email sent.", {
252
+ ...messageProperties,
253
+ event: "email.sent",
254
+ operation,
255
+ transportId: this.id,
256
+ durationMilliseconds,
257
+ messageId: receipt.messageId,
258
+ provider: receipt.provider ?? this.id,
259
+ receipt
260
+ });
261
+ return;
262
+ }
263
+ this.log(this.config.levels.failed, "Failed to send email.", {
264
+ ...messageProperties,
265
+ event: "email.failed",
266
+ operation,
267
+ transportId: this.id,
268
+ durationMilliseconds,
269
+ errorMessages: receipt.errorMessages,
270
+ errors: receipt.errors,
271
+ retryable: receipt.retryable,
272
+ provider: receipt.provider ?? this.id,
273
+ attempts: receipt.attempts,
274
+ receipt
275
+ });
276
+ }
277
+ logThrownError(error, message, operation, startedAt, extraProperties = {}) {
278
+ this.log(this.config.levels.failed, "Failed to send email: {error}", {
279
+ ...message == null ? {} : this.getMessageProperties(message),
280
+ ...extraProperties,
281
+ event: "email.failed",
282
+ operation,
283
+ transportId: this.id,
284
+ durationMilliseconds: performance.now() - startedAt,
285
+ error
286
+ });
287
+ }
288
+ getMessageProperties(message) {
289
+ return {
290
+ recipientCount: message.recipients.length,
291
+ ccRecipientCount: message.ccRecipients.length,
292
+ bccRecipientCount: message.bccRecipients.length,
293
+ attachmentCount: message.attachments.length,
294
+ priority: message.priority,
295
+ ...this.config.recordMessage ? { message } : {}
296
+ };
297
+ }
298
+ log(level, message, properties) {
299
+ switch (level) {
300
+ case "trace":
301
+ this.logger.trace(message, properties);
302
+ break;
303
+ case "debug":
304
+ this.logger.debug(message, properties);
305
+ break;
306
+ case "info":
307
+ this.logger.info(message, properties);
308
+ break;
309
+ case "warning":
310
+ this.logger.warning(message, properties);
311
+ break;
312
+ case "error":
313
+ this.logger.error(message, properties);
314
+ break;
315
+ case "fatal":
316
+ this.logger.fatal(message, properties);
317
+ break;
318
+ }
319
+ }
320
+ };
321
+ function isAsyncDisposable(value) {
322
+ return Symbol.asyncDispose in value && typeof value[Symbol.asyncDispose] === "function";
323
+ }
324
+ function isDisposable(value) {
325
+ return Symbol.dispose in value && typeof value[Symbol.dispose] === "function";
326
+ }
327
+
328
+ //#endregion
329
+ exports.LogTapeTransport = LogTapeTransport;
@@ -0,0 +1,157 @@
1
+ import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
2
+ import { LogLevel } from "@logtape/logtape";
3
+
4
+ //#region src/config.d.ts
5
+
6
+ /**
7
+ * Log levels used for email delivery lifecycle events.
8
+ *
9
+ * @since 0.6.0
10
+ */
11
+ interface LogTapeTransportLevels {
12
+ /**
13
+ * Level used before an email is handed to the transport.
14
+ *
15
+ * @default "debug"
16
+ */
17
+ readonly sending?: LogLevel;
18
+ /**
19
+ * Level used after an email is sent successfully.
20
+ *
21
+ * @default "info"
22
+ */
23
+ readonly sent?: LogLevel;
24
+ /**
25
+ * Level used when a transport returns a failed receipt or throws.
26
+ *
27
+ * @default "error"
28
+ */
29
+ readonly failed?: LogLevel;
30
+ }
31
+ /**
32
+ * Options for {@link LogTapeTransport}.
33
+ *
34
+ * @typeParam TProviderId The provider id of the wrapped transport.
35
+ * @since 0.6.0
36
+ */
37
+ interface LogTapeTransportOptions<TProviderId extends string = "logtape"> {
38
+ /**
39
+ * Transport that performs the actual delivery.
40
+ *
41
+ * When omitted, the LogTape transport only logs the delivery and returns a
42
+ * synthetic successful receipt.
43
+ */
44
+ readonly transport?: Transport<TProviderId>;
45
+ /**
46
+ * LogTape category used for delivery logs.
47
+ *
48
+ * @default ["upyo"]
49
+ */
50
+ readonly category?: string | readonly string[];
51
+ /**
52
+ * Whether to include the complete email message in structured log
53
+ * properties.
54
+ *
55
+ * Messages may contain sensitive or large values, including email bodies,
56
+ * headers, and attachment data.
57
+ *
58
+ * @default false
59
+ */
60
+ readonly recordMessage?: boolean;
61
+ /**
62
+ * Levels used for delivery lifecycle events.
63
+ */
64
+ readonly levels?: LogTapeTransportLevels;
65
+ }
66
+ /**
67
+ * Fully resolved options used by {@link LogTapeTransport}.
68
+ *
69
+ * @typeParam TProviderId The provider id of the wrapped transport.
70
+ * @since 0.6.0
71
+ */
72
+ interface ResolvedLogTapeTransportOptions<TProviderId extends string = "logtape"> {
73
+ /** Transport that performs the actual delivery, if configured. */
74
+ readonly transport?: Transport<TProviderId>;
75
+ /** Resolved LogTape category. */
76
+ readonly category: readonly string[];
77
+ /** Whether complete messages are included in structured logs. */
78
+ readonly recordMessage: boolean;
79
+ /** Resolved delivery lifecycle log levels. */
80
+ readonly levels: Required<LogTapeTransportLevels>;
81
+ }
82
+ /**
83
+ * Resolves LogTape transport options with their defaults.
84
+ *
85
+ * @param options User-provided transport options.
86
+ * @returns Fully resolved transport options.
87
+ * @since 0.6.0
88
+ */
89
+ //#endregion
90
+ //#region src/logtape-transport.d.ts
91
+ type LogTapeTransportConstructorArguments<TProviderId extends string> = [TProviderId] extends ["logtape"] ? [options?: LogTapeTransportOptions<TProviderId>] : [options: LogTapeTransportOptions<TProviderId> & {
92
+ readonly transport: Transport<TProviderId>;
93
+ }];
94
+ /**
95
+ * Transport that records email delivery lifecycle events through LogTape.
96
+ *
97
+ * With no wrapped transport, this class acts as a log-only transport and
98
+ * returns synthetic successful receipts. When a transport is supplied, it
99
+ * decorates that transport without changing its receipts or thrown errors.
100
+ *
101
+ * @typeParam TProviderId The provider id of the wrapped transport, or
102
+ * `"logtape"` in log-only mode.
103
+ * @since 0.6.0
104
+ */
105
+ declare class LogTapeTransport<TProviderId extends string = "logtape"> implements Transport<TProviderId>, AsyncDisposable {
106
+ /** Provider id used by receipts from this transport. */
107
+ readonly id: TProviderId;
108
+ /** Fully resolved transport options. */
109
+ readonly config: ResolvedLogTapeTransportOptions<TProviderId>;
110
+ private readonly logger;
111
+ private readonly wrappedTransport?;
112
+ /**
113
+ * Creates a LogTape transport.
114
+ *
115
+ * @param options Logging and optional wrapped transport configuration.
116
+ */
117
+ constructor(...[options]: LogTapeTransportConstructorArguments<TProviderId>);
118
+ /**
119
+ * Sends one email while recording its delivery lifecycle.
120
+ *
121
+ * @param message The email message to send.
122
+ * @param options Optional transport options, including cancellation.
123
+ * @returns The wrapped transport receipt, or a synthetic successful receipt
124
+ * in log-only mode.
125
+ * @throws {DOMException} If the operation is aborted.
126
+ * @throws {Error} If the wrapped transport throws an error.
127
+ */
128
+ send(message: Message, options?: TransportOptions): Promise<Receipt<TProviderId>>;
129
+ /**
130
+ * Sends multiple emails while recording each delivery lifecycle.
131
+ *
132
+ * A wrapped transport's `sendMany()` implementation is used directly so
133
+ * provider-specific batching and streaming behavior are preserved.
134
+ *
135
+ * @param messages Email messages to send.
136
+ * @param options Optional transport options, including cancellation.
137
+ * @returns An async iterable of unmodified delivery receipts.
138
+ * @throws {DOMException} If the operation is aborted.
139
+ * @throws {Error} If the wrapped transport throws an error.
140
+ */
141
+ sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<Receipt<TProviderId>>;
142
+ /**
143
+ * Disposes the wrapped transport when it supports explicit resource
144
+ * management.
145
+ */
146
+ [Symbol.asyncDispose](): Promise<void>;
147
+ private sendOne;
148
+ private observeMessages;
149
+ private createSyntheticReceipt;
150
+ private logSending;
151
+ private logReceipt;
152
+ private logThrownError;
153
+ private getMessageProperties;
154
+ private log;
155
+ }
156
+ //#endregion
157
+ export { LogTapeTransport, LogTapeTransportLevels, LogTapeTransportOptions, ResolvedLogTapeTransportOptions };
@@ -0,0 +1,157 @@
1
+ import { LogLevel } from "@logtape/logtape";
2
+ import { Message, Receipt, Transport, TransportOptions } from "@upyo/core";
3
+
4
+ //#region src/config.d.ts
5
+
6
+ /**
7
+ * Log levels used for email delivery lifecycle events.
8
+ *
9
+ * @since 0.6.0
10
+ */
11
+ interface LogTapeTransportLevels {
12
+ /**
13
+ * Level used before an email is handed to the transport.
14
+ *
15
+ * @default "debug"
16
+ */
17
+ readonly sending?: LogLevel;
18
+ /**
19
+ * Level used after an email is sent successfully.
20
+ *
21
+ * @default "info"
22
+ */
23
+ readonly sent?: LogLevel;
24
+ /**
25
+ * Level used when a transport returns a failed receipt or throws.
26
+ *
27
+ * @default "error"
28
+ */
29
+ readonly failed?: LogLevel;
30
+ }
31
+ /**
32
+ * Options for {@link LogTapeTransport}.
33
+ *
34
+ * @typeParam TProviderId The provider id of the wrapped transport.
35
+ * @since 0.6.0
36
+ */
37
+ interface LogTapeTransportOptions<TProviderId extends string = "logtape"> {
38
+ /**
39
+ * Transport that performs the actual delivery.
40
+ *
41
+ * When omitted, the LogTape transport only logs the delivery and returns a
42
+ * synthetic successful receipt.
43
+ */
44
+ readonly transport?: Transport<TProviderId>;
45
+ /**
46
+ * LogTape category used for delivery logs.
47
+ *
48
+ * @default ["upyo"]
49
+ */
50
+ readonly category?: string | readonly string[];
51
+ /**
52
+ * Whether to include the complete email message in structured log
53
+ * properties.
54
+ *
55
+ * Messages may contain sensitive or large values, including email bodies,
56
+ * headers, and attachment data.
57
+ *
58
+ * @default false
59
+ */
60
+ readonly recordMessage?: boolean;
61
+ /**
62
+ * Levels used for delivery lifecycle events.
63
+ */
64
+ readonly levels?: LogTapeTransportLevels;
65
+ }
66
+ /**
67
+ * Fully resolved options used by {@link LogTapeTransport}.
68
+ *
69
+ * @typeParam TProviderId The provider id of the wrapped transport.
70
+ * @since 0.6.0
71
+ */
72
+ interface ResolvedLogTapeTransportOptions<TProviderId extends string = "logtape"> {
73
+ /** Transport that performs the actual delivery, if configured. */
74
+ readonly transport?: Transport<TProviderId>;
75
+ /** Resolved LogTape category. */
76
+ readonly category: readonly string[];
77
+ /** Whether complete messages are included in structured logs. */
78
+ readonly recordMessage: boolean;
79
+ /** Resolved delivery lifecycle log levels. */
80
+ readonly levels: Required<LogTapeTransportLevels>;
81
+ }
82
+ /**
83
+ * Resolves LogTape transport options with their defaults.
84
+ *
85
+ * @param options User-provided transport options.
86
+ * @returns Fully resolved transport options.
87
+ * @since 0.6.0
88
+ */
89
+ //#endregion
90
+ //#region src/logtape-transport.d.ts
91
+ type LogTapeTransportConstructorArguments<TProviderId extends string> = [TProviderId] extends ["logtape"] ? [options?: LogTapeTransportOptions<TProviderId>] : [options: LogTapeTransportOptions<TProviderId> & {
92
+ readonly transport: Transport<TProviderId>;
93
+ }];
94
+ /**
95
+ * Transport that records email delivery lifecycle events through LogTape.
96
+ *
97
+ * With no wrapped transport, this class acts as a log-only transport and
98
+ * returns synthetic successful receipts. When a transport is supplied, it
99
+ * decorates that transport without changing its receipts or thrown errors.
100
+ *
101
+ * @typeParam TProviderId The provider id of the wrapped transport, or
102
+ * `"logtape"` in log-only mode.
103
+ * @since 0.6.0
104
+ */
105
+ declare class LogTapeTransport<TProviderId extends string = "logtape"> implements Transport<TProviderId>, AsyncDisposable {
106
+ /** Provider id used by receipts from this transport. */
107
+ readonly id: TProviderId;
108
+ /** Fully resolved transport options. */
109
+ readonly config: ResolvedLogTapeTransportOptions<TProviderId>;
110
+ private readonly logger;
111
+ private readonly wrappedTransport?;
112
+ /**
113
+ * Creates a LogTape transport.
114
+ *
115
+ * @param options Logging and optional wrapped transport configuration.
116
+ */
117
+ constructor(...[options]: LogTapeTransportConstructorArguments<TProviderId>);
118
+ /**
119
+ * Sends one email while recording its delivery lifecycle.
120
+ *
121
+ * @param message The email message to send.
122
+ * @param options Optional transport options, including cancellation.
123
+ * @returns The wrapped transport receipt, or a synthetic successful receipt
124
+ * in log-only mode.
125
+ * @throws {DOMException} If the operation is aborted.
126
+ * @throws {Error} If the wrapped transport throws an error.
127
+ */
128
+ send(message: Message, options?: TransportOptions): Promise<Receipt<TProviderId>>;
129
+ /**
130
+ * Sends multiple emails while recording each delivery lifecycle.
131
+ *
132
+ * A wrapped transport's `sendMany()` implementation is used directly so
133
+ * provider-specific batching and streaming behavior are preserved.
134
+ *
135
+ * @param messages Email messages to send.
136
+ * @param options Optional transport options, including cancellation.
137
+ * @returns An async iterable of unmodified delivery receipts.
138
+ * @throws {DOMException} If the operation is aborted.
139
+ * @throws {Error} If the wrapped transport throws an error.
140
+ */
141
+ sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<Receipt<TProviderId>>;
142
+ /**
143
+ * Disposes the wrapped transport when it supports explicit resource
144
+ * management.
145
+ */
146
+ [Symbol.asyncDispose](): Promise<void>;
147
+ private sendOne;
148
+ private observeMessages;
149
+ private createSyntheticReceipt;
150
+ private logSending;
151
+ private logReceipt;
152
+ private logThrownError;
153
+ private getMessageProperties;
154
+ private log;
155
+ }
156
+ //#endregion
157
+ export { LogTapeTransport, LogTapeTransportLevels, LogTapeTransportOptions, ResolvedLogTapeTransportOptions };
package/dist/index.js ADDED
@@ -0,0 +1,306 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+
3
+ //#region src/config.ts
4
+ /**
5
+ * Resolves LogTape transport options with their defaults.
6
+ *
7
+ * @param options User-provided transport options.
8
+ * @returns Fully resolved transport options.
9
+ * @since 0.6.0
10
+ */
11
+ function resolveLogTapeTransportOptions(options) {
12
+ const category = options.category ?? ["upyo"];
13
+ return {
14
+ transport: options.transport,
15
+ category: typeof category === "string" ? [category] : [...category],
16
+ recordMessage: options.recordMessage ?? false,
17
+ levels: {
18
+ sending: options.levels?.sending ?? "debug",
19
+ sent: options.levels?.sent ?? "info",
20
+ failed: options.levels?.failed ?? "error"
21
+ }
22
+ };
23
+ }
24
+
25
+ //#endregion
26
+ //#region src/pending-queue.ts
27
+ /**
28
+ * Internal linked FIFO queue for messages awaiting delivery receipts.
29
+ *
30
+ * Both enqueue and dequeue operations take constant time, without retaining
31
+ * references to values that have already been dequeued.
32
+ *
33
+ * @typeParam T The queued value type.
34
+ * @since 0.6.0
35
+ */
36
+ var PendingQueue = class {
37
+ head;
38
+ tail;
39
+ valueCount = 0;
40
+ /** Number of values waiting in the queue. */
41
+ get size() {
42
+ return this.valueCount;
43
+ }
44
+ /**
45
+ * Adds a value to the end of the queue.
46
+ *
47
+ * @param value The value to enqueue.
48
+ */
49
+ enqueue(value) {
50
+ const node = { value };
51
+ if (this.tail == null) this.head = node;
52
+ else this.tail.next = node;
53
+ this.tail = node;
54
+ this.valueCount++;
55
+ }
56
+ /**
57
+ * Removes and returns the value at the front of the queue.
58
+ *
59
+ * @returns The first queued value, or `undefined` when the queue is empty.
60
+ */
61
+ dequeue() {
62
+ const node = this.head;
63
+ if (node == null) return void 0;
64
+ this.head = node.next;
65
+ node.next = void 0;
66
+ this.valueCount--;
67
+ if (this.head == null) this.tail = void 0;
68
+ return node.value;
69
+ }
70
+ /** Iterates over queued values without removing them. */
71
+ *[Symbol.iterator]() {
72
+ let node = this.head;
73
+ while (node != null) {
74
+ yield node.value;
75
+ node = node.next;
76
+ }
77
+ }
78
+ };
79
+
80
+ //#endregion
81
+ //#region src/logtape-transport.ts
82
+ /**
83
+ * Transport that records email delivery lifecycle events through LogTape.
84
+ *
85
+ * With no wrapped transport, this class acts as a log-only transport and
86
+ * returns synthetic successful receipts. When a transport is supplied, it
87
+ * decorates that transport without changing its receipts or thrown errors.
88
+ *
89
+ * @typeParam TProviderId The provider id of the wrapped transport, or
90
+ * `"logtape"` in log-only mode.
91
+ * @since 0.6.0
92
+ */
93
+ var LogTapeTransport = class {
94
+ /** Provider id used by receipts from this transport. */
95
+ id;
96
+ /** Fully resolved transport options. */
97
+ config;
98
+ logger;
99
+ wrappedTransport;
100
+ /**
101
+ * Creates a LogTape transport.
102
+ *
103
+ * @param options Logging and optional wrapped transport configuration.
104
+ */
105
+ constructor(...[options]) {
106
+ this.config = resolveLogTapeTransportOptions(options ?? {});
107
+ this.wrappedTransport = this.config.transport;
108
+ this.id = this.wrappedTransport?.id ?? "logtape";
109
+ this.logger = getLogger(this.config.category);
110
+ }
111
+ /**
112
+ * Sends one email while recording its delivery lifecycle.
113
+ *
114
+ * @param message The email message to send.
115
+ * @param options Optional transport options, including cancellation.
116
+ * @returns The wrapped transport receipt, or a synthetic successful receipt
117
+ * in log-only mode.
118
+ * @throws {DOMException} If the operation is aborted.
119
+ * @throws {Error} If the wrapped transport throws an error.
120
+ */
121
+ send(message, options) {
122
+ return this.sendOne(message, options, "send");
123
+ }
124
+ /**
125
+ * Sends multiple emails while recording each delivery lifecycle.
126
+ *
127
+ * A wrapped transport's `sendMany()` implementation is used directly so
128
+ * provider-specific batching and streaming behavior are preserved.
129
+ *
130
+ * @param messages Email messages to send.
131
+ * @param options Optional transport options, including cancellation.
132
+ * @returns An async iterable of unmodified delivery receipts.
133
+ * @throws {DOMException} If the operation is aborted.
134
+ * @throws {Error} If the wrapped transport throws an error.
135
+ */
136
+ async *sendMany(messages, options) {
137
+ options?.signal?.throwIfAborted();
138
+ if (this.wrappedTransport == null) {
139
+ for await (const message of messages) yield await this.sendOne(message, options, "sendMany");
140
+ return;
141
+ }
142
+ const pending = new PendingQueue();
143
+ const batchStartedAt = performance.now();
144
+ let consumedCount = 0;
145
+ let completedCount = 0;
146
+ const observedMessages = this.observeMessages(messages, pending, options, () => consumedCount++);
147
+ try {
148
+ for await (const receipt of this.wrappedTransport.sendMany(observedMessages, options)) {
149
+ const current = pending.dequeue();
150
+ completedCount++;
151
+ this.logReceipt(receipt, current, "sendMany");
152
+ yield receipt;
153
+ }
154
+ } catch (error) {
155
+ const batchProperties = {
156
+ consumedCount,
157
+ completedCount,
158
+ pendingCount: pending.size
159
+ };
160
+ if (pending.size < 1) this.logThrownError(error, void 0, "sendMany", batchStartedAt, batchProperties);
161
+ else for (const pendingMessage of pending) this.logThrownError(error, pendingMessage.message, "sendMany", pendingMessage.startedAt, batchProperties);
162
+ throw error;
163
+ }
164
+ }
165
+ /**
166
+ * Disposes the wrapped transport when it supports explicit resource
167
+ * management.
168
+ */
169
+ async [Symbol.asyncDispose]() {
170
+ const transport = this.wrappedTransport;
171
+ if (transport == null) return;
172
+ if (isAsyncDisposable(transport)) {
173
+ await transport[Symbol.asyncDispose]();
174
+ return;
175
+ }
176
+ if (isDisposable(transport)) transport[Symbol.dispose]();
177
+ }
178
+ async sendOne(message, options, operation) {
179
+ options?.signal?.throwIfAborted();
180
+ const startedAt = performance.now();
181
+ this.logSending(message, operation);
182
+ try {
183
+ const receipt = this.wrappedTransport == null ? this.createSyntheticReceipt() : await this.wrappedTransport.send(message, options);
184
+ this.logReceipt(receipt, {
185
+ message,
186
+ startedAt
187
+ }, operation);
188
+ return receipt;
189
+ } catch (error) {
190
+ this.logThrownError(error, message, operation, startedAt);
191
+ throw error;
192
+ }
193
+ }
194
+ async *observeMessages(messages, pending, options, onConsume) {
195
+ for await (const message of messages) {
196
+ options?.signal?.throwIfAborted();
197
+ const startedAt = performance.now();
198
+ this.logSending(message, "sendMany");
199
+ pending.enqueue({
200
+ message,
201
+ startedAt
202
+ });
203
+ onConsume();
204
+ yield message;
205
+ }
206
+ }
207
+ createSyntheticReceipt() {
208
+ return {
209
+ successful: true,
210
+ messageId: `logtape-${crypto.randomUUID()}`,
211
+ provider: this.id,
212
+ attempts: 1,
213
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
214
+ };
215
+ }
216
+ logSending(message, operation) {
217
+ this.log(this.config.levels.sending, "Sending email.", {
218
+ ...this.getMessageProperties(message),
219
+ event: "email.sending",
220
+ operation,
221
+ transportId: this.id
222
+ });
223
+ }
224
+ logReceipt(receipt, pending, operation) {
225
+ const durationMilliseconds = pending == null ? void 0 : performance.now() - pending.startedAt;
226
+ const messageProperties = pending == null ? {} : this.getMessageProperties(pending.message);
227
+ if (receipt.successful) {
228
+ this.log(this.config.levels.sent, "Email sent.", {
229
+ ...messageProperties,
230
+ event: "email.sent",
231
+ operation,
232
+ transportId: this.id,
233
+ durationMilliseconds,
234
+ messageId: receipt.messageId,
235
+ provider: receipt.provider ?? this.id,
236
+ receipt
237
+ });
238
+ return;
239
+ }
240
+ this.log(this.config.levels.failed, "Failed to send email.", {
241
+ ...messageProperties,
242
+ event: "email.failed",
243
+ operation,
244
+ transportId: this.id,
245
+ durationMilliseconds,
246
+ errorMessages: receipt.errorMessages,
247
+ errors: receipt.errors,
248
+ retryable: receipt.retryable,
249
+ provider: receipt.provider ?? this.id,
250
+ attempts: receipt.attempts,
251
+ receipt
252
+ });
253
+ }
254
+ logThrownError(error, message, operation, startedAt, extraProperties = {}) {
255
+ this.log(this.config.levels.failed, "Failed to send email: {error}", {
256
+ ...message == null ? {} : this.getMessageProperties(message),
257
+ ...extraProperties,
258
+ event: "email.failed",
259
+ operation,
260
+ transportId: this.id,
261
+ durationMilliseconds: performance.now() - startedAt,
262
+ error
263
+ });
264
+ }
265
+ getMessageProperties(message) {
266
+ return {
267
+ recipientCount: message.recipients.length,
268
+ ccRecipientCount: message.ccRecipients.length,
269
+ bccRecipientCount: message.bccRecipients.length,
270
+ attachmentCount: message.attachments.length,
271
+ priority: message.priority,
272
+ ...this.config.recordMessage ? { message } : {}
273
+ };
274
+ }
275
+ log(level, message, properties) {
276
+ switch (level) {
277
+ case "trace":
278
+ this.logger.trace(message, properties);
279
+ break;
280
+ case "debug":
281
+ this.logger.debug(message, properties);
282
+ break;
283
+ case "info":
284
+ this.logger.info(message, properties);
285
+ break;
286
+ case "warning":
287
+ this.logger.warning(message, properties);
288
+ break;
289
+ case "error":
290
+ this.logger.error(message, properties);
291
+ break;
292
+ case "fatal":
293
+ this.logger.fatal(message, properties);
294
+ break;
295
+ }
296
+ }
297
+ };
298
+ function isAsyncDisposable(value) {
299
+ return Symbol.asyncDispose in value && typeof value[Symbol.asyncDispose] === "function";
300
+ }
301
+ function isDisposable(value) {
302
+ return Symbol.dispose in value && typeof value[Symbol.dispose] === "function";
303
+ }
304
+
305
+ //#endregion
306
+ export { LogTapeTransport };
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@upyo/logtape",
3
+ "version": "0.6.0-dev.0",
4
+ "description": "LogTape observability transport for Upyo email library",
5
+ "keywords": [
6
+ "email",
7
+ "mail",
8
+ "logtape",
9
+ "logging",
10
+ "observability"
11
+ ],
12
+ "license": "MIT",
13
+ "author": {
14
+ "name": "Hong Minhee",
15
+ "email": "hong@minhee.org",
16
+ "url": "https://hongminhee.org/"
17
+ },
18
+ "homepage": "https://upyo.org/transports/logtape",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/dahlia/upyo.git",
22
+ "directory": "packages/logtape/"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/dahlia/upyo/issues"
26
+ },
27
+ "funding": [
28
+ "https://github.com/sponsors/dahlia"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20.0.0",
32
+ "bun": ">=1.2.0",
33
+ "deno": ">=2.3.0"
34
+ },
35
+ "files": [
36
+ "dist/",
37
+ "package.json",
38
+ "README.md"
39
+ ],
40
+ "type": "module",
41
+ "module": "./dist/index.js",
42
+ "main": "./dist/index.cjs",
43
+ "types": "./dist/index.d.ts",
44
+ "exports": {
45
+ ".": {
46
+ "types": {
47
+ "import": "./dist/index.d.ts",
48
+ "require": "./dist/index.d.cts"
49
+ },
50
+ "import": "./dist/index.js",
51
+ "require": "./dist/index.cjs"
52
+ },
53
+ "./package.json": "./package.json"
54
+ },
55
+ "sideEffects": false,
56
+ "peerDependencies": {
57
+ "@logtape/logtape": "^2.2.4",
58
+ "@upyo/core": "0.6.0"
59
+ },
60
+ "devDependencies": {
61
+ "@logtape/logtape": "^2.2.4",
62
+ "@logtape/testing": "^2.2.4",
63
+ "tsdown": "^0.12.7",
64
+ "typescript": "5.8.3"
65
+ },
66
+ "scripts": {
67
+ "prepublish": "mise run --no-deps :build"
68
+ }
69
+ }