@zhin.js/adapter-email 1.0.0 → 1.1.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/lib/client.js ADDED
@@ -0,0 +1,29 @@
1
+ import { defineEndpointClient } from 'zhin.js/adapter';
2
+ /** Live SMTP + IMAP client pair exposed to event handlers and plugins. */
3
+ export class EmailClient {
4
+ resolveSmtp;
5
+ resolveImap;
6
+ constructor(resolveSmtp, resolveImap) {
7
+ this.resolveSmtp = resolveSmtp;
8
+ this.resolveImap = resolveImap;
9
+ }
10
+ get smtp() {
11
+ const transport = this.resolveSmtp();
12
+ if (!transport)
13
+ throw new Error('SMTP transporter not connected');
14
+ return transport;
15
+ }
16
+ get imap() {
17
+ const transport = this.resolveImap();
18
+ if (!transport)
19
+ throw new Error('IMAP client not connected');
20
+ return transport;
21
+ }
22
+ verify() {
23
+ return this.smtp.verify();
24
+ }
25
+ sendMail(options) {
26
+ return this.smtp.sendMail(options);
27
+ }
28
+ }
29
+ export const emailClient = defineEndpointClient('email');
@@ -0,0 +1,28 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
3
+ import type { CapabilityId } from 'zhin.js';
4
+ import { type EmailMessage, type ResolvedEmailConfig } from './protocol.js';
5
+ import { type EmailImapTransport, type EmailSmtpTransport } from './transport.js';
6
+ import { EmailClient } from './client.js';
7
+ export interface EmailEndpointOptions {
8
+ readonly id: CapabilityId;
9
+ readonly config: ResolvedEmailConfig;
10
+ readonly createSmtp?: (config: ResolvedEmailConfig['smtp']) => EmailSmtpTransport | Promise<EmailSmtpTransport>;
11
+ readonly createImap?: (config: ResolvedEmailConfig['imap']) => EmailImapTransport;
12
+ }
13
+ /**
14
+ * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
15
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
16
+ */
17
+ export declare class EmailEndpoint extends Endpoint<EmailClient> {
18
+ #private;
19
+ readonly client: EmailClient;
20
+ constructor(options: EmailEndpointOptions);
21
+ start(): Promise<void>;
22
+ open(): void;
23
+ close(): void;
24
+ stop(): Promise<void>;
25
+ send({ conversation, payload }: EndpointSendRequest): Promise<string>;
26
+ /** Test / internal: admit a parsed mail when the endpoint is open. */
27
+ admit(email: EmailMessage): void;
28
+ }
@@ -0,0 +1,335 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ /**
3
+ * EmailEndpoint — lifecycle, SMTP outbound, IMAP inbound polling.
4
+ */
5
+ import { mkdir, writeFile } from 'node:fs/promises';
6
+ import * as path from 'node:path';
7
+ import { simpleParser } from 'mailparser';
8
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
9
+ import { emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, parseEmailMessage, senderDisplayName, } from './protocol.js';
10
+ import { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
11
+ import { EmailClient } from './client.js';
12
+ /**
13
+ * Email(SMTP/IMAP)无好友/群/频道等社交图谱概念,
14
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
15
+ */
16
+ export class EmailEndpoint extends Endpoint {
17
+ client;
18
+ #logger;
19
+ #options;
20
+ #smtp = null;
21
+ #imap = null;
22
+ #checkTimer = null;
23
+ #reconnectTimer = null;
24
+ #reconnectAttempts = 0;
25
+ #checking = false;
26
+ #open = false;
27
+ #started = false;
28
+ constructor(options) {
29
+ super();
30
+ this.#logger = getAdapterLogger('email', options.config.id);
31
+ this.#options = options;
32
+ this.client = new EmailClient(() => this.#smtp, () => this.#imap);
33
+ }
34
+ async start() {
35
+ if (this.#started)
36
+ return;
37
+ this.#started = true;
38
+ const { smtp, imap, id } = this.#options.config;
39
+ try {
40
+ this.#smtp = await (this.#options.createSmtp?.(smtp) ?? defaultCreateSmtp(smtp));
41
+ await this.#smtp.verify();
42
+ this.#logger.debug(formatCompact({ mode: 'smtp' }));
43
+ this.#imap = this.#options.createImap?.(imap) ?? defaultCreateImap(imap);
44
+ this.#setupImapListeners(this.#imap);
45
+ await new Promise((resolve, reject) => {
46
+ this.#imap.once('ready', () => {
47
+ void this.#emitPlatformEvent('imap.ready', Object.freeze({}));
48
+ resolve();
49
+ });
50
+ this.#imap.once('error', (error) => reject(error));
51
+ this.#imap.connect();
52
+ });
53
+ this.#logger.debug(formatCompact({ mode: 'imap' }));
54
+ this.#reconnectAttempts = 0;
55
+ this.#startEmailCheck();
56
+ }
57
+ catch (error) {
58
+ await this.stop();
59
+ this.#logger.error('Failed to connect email services:', error);
60
+ throw error;
61
+ }
62
+ }
63
+ open() {
64
+ this.#open = true;
65
+ }
66
+ close() {
67
+ this.#open = false;
68
+ }
69
+ async stop() {
70
+ this.#open = false;
71
+ // 先复位 #started,避免 imap.end() 触发的 'end' 事件又武装重连定时器
72
+ this.#started = false;
73
+ if (this.#checkTimer) {
74
+ clearInterval(this.#checkTimer);
75
+ this.#checkTimer = null;
76
+ }
77
+ if (this.#reconnectTimer) {
78
+ clearTimeout(this.#reconnectTimer);
79
+ this.#reconnectTimer = null;
80
+ }
81
+ if (this.#imap) {
82
+ try {
83
+ this.#imap.end();
84
+ }
85
+ catch {
86
+ /* ignore */
87
+ }
88
+ this.#imap = null;
89
+ }
90
+ if (this.#smtp) {
91
+ try {
92
+ this.#smtp.close();
93
+ }
94
+ catch {
95
+ /* ignore */
96
+ }
97
+ this.#smtp = null;
98
+ }
99
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
100
+ }
101
+ async send({ conversation, payload }) {
102
+ const target = conversation.id;
103
+ const mailOptions = formatOutboundMail(payload, {
104
+ from: this.#options.config.smtp.auth.user,
105
+ to: target,
106
+ });
107
+ const info = await this.client.sendMail(mailOptions);
108
+ this.#logger.debug(formatCompact({ op: 'email_send', target, messageId: info.messageId }));
109
+ return info.messageId || '';
110
+ }
111
+ /** Test / internal: admit a parsed mail when the endpoint is open. */
112
+ admit(email) {
113
+ if (!this.#open)
114
+ return;
115
+ void this.#emitPlatformEvent('mail', email);
116
+ void this.#admitWithAttachments(email).catch((err) => {
117
+ this.#logger.warn(formatCompact({
118
+ op: 'email_gateway_receive_failed',
119
+ target: email.from,
120
+ error: err instanceof Error ? err.message : String(err),
121
+ }));
122
+ });
123
+ }
124
+ async #admitWithAttachments(email) {
125
+ const savedAttachments = await this.#downloadAttachments(email);
126
+ const content = formatInboundContent(email);
127
+ const sender = email.from;
128
+ const conversation = emailInboundConversation(String(this.#options.id), email);
129
+ await this.emit('message.receive', {
130
+ conversation,
131
+ ...(email.messageId ? { message: { conversation, id: email.messageId } } : {}),
132
+ content,
133
+ segments: formatInboundSegments(email, savedAttachments),
134
+ sender: { id: sender, name: senderDisplayName(sender) || undefined },
135
+ endpointId: this.#options.config.id,
136
+ metadata: Object.freeze({
137
+ subject: email.subject,
138
+ to: email.to,
139
+ cc: email.cc,
140
+ uid: email.uid,
141
+ date: email.date.toISOString(),
142
+ ...(savedAttachments.length ? { attachments: savedAttachments } : {}),
143
+ }),
144
+ });
145
+ }
146
+ /**
147
+ * attachments.enabled 时把入站附件落盘(恢复旧 downloadAttachment 行为,
148
+ * 附加 maxFileSize / allowedTypes 过滤);返回落盘结果供 admit segments/metadata 使用。
149
+ */
150
+ async #downloadAttachments(email) {
151
+ const config = this.#options.config.attachments;
152
+ if (!config?.enabled || email.attachments.length === 0)
153
+ return [];
154
+ await mkdir(config.downloadPath, { recursive: true });
155
+ const downloadRoot = path.resolve(config.downloadPath);
156
+ const saved = [];
157
+ for (const attachment of email.attachments) {
158
+ // 防路径穿越:发件人可构造 ../../ 等文件名,basename + resolve 后必须落在 downloadPath 内
159
+ const rawName = attachment.filename || `attachment_${Date.now()}`;
160
+ const filename = path.basename(rawName) || `attachment_${Date.now()}`;
161
+ const filepath = path.resolve(downloadRoot, filename);
162
+ if (filepath !== downloadRoot && !filepath.startsWith(downloadRoot + path.sep)) {
163
+ this.#logger.warn(formatCompact({ op: 'email_attachment_skipped', filename: rawName, reason: 'path' }));
164
+ continue;
165
+ }
166
+ if (config.allowedTypes?.length && !config.allowedTypes.includes(attachment.contentType ?? '')) {
167
+ this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'type' }));
168
+ continue;
169
+ }
170
+ if (attachment.size != null && attachment.size > config.maxFileSize) {
171
+ this.#logger.debug(formatCompact({ op: 'email_attachment_skipped', filename, reason: 'size' }));
172
+ continue;
173
+ }
174
+ try {
175
+ await writeFile(filepath, attachment.content);
176
+ saved.push({ filename, path: filepath, contentType: attachment.contentType, size: attachment.size });
177
+ }
178
+ catch (error) {
179
+ this.#logger.warn(formatCompact({
180
+ op: 'email_attachment_download_failed',
181
+ filename,
182
+ error: error instanceof Error ? error.message : String(error),
183
+ }));
184
+ }
185
+ }
186
+ return saved;
187
+ }
188
+ #setupImapListeners(imap) {
189
+ imap.on('mail', () => {
190
+ void this.#emitPlatformEvent('imap.mail', Object.freeze({}));
191
+ void this.#checkForNewEmails();
192
+ });
193
+ imap.on('error', (error) => {
194
+ void this.#emitPlatformEvent('imap.error', error);
195
+ this.#logger.error('IMAP error:', error);
196
+ // imap 通常在 error 后紧跟 end;两处都调度,靠已有定时器去重
197
+ this.#scheduleImapReconnect(imap);
198
+ });
199
+ imap.on('end', () => {
200
+ void this.#emitPlatformEvent('imap.end', Object.freeze({}));
201
+ this.#logger.debug(formatCompact({
202
+ op: 'disconnect',
203
+ mode: 'imap',
204
+ }));
205
+ this.#scheduleImapReconnect(imap);
206
+ });
207
+ }
208
+ async #emitPlatformEvent(name, event) {
209
+ await this.emitPlatform(name, event).catch((error) => {
210
+ this.#logger.warn(formatCompact({
211
+ op: 'email_platform_event_failed',
212
+ event: name,
213
+ error: error instanceof Error ? error.message : String(error),
214
+ }));
215
+ });
216
+ }
217
+ /** IMAP 断线后按指数退避重建连接并恢复监听(基数 reconnectInterval,封顶 5 分钟)。 */
218
+ #scheduleImapReconnect(source) {
219
+ // A replaced transport may emit a late `end` after its earlier `error`
220
+ // already caused a successful reconnect. Only the currently owned IMAP
221
+ // connection may arm the next generation's reconnect timer.
222
+ if (!this.#started || this.#reconnectTimer || (source && this.#imap !== source))
223
+ return;
224
+ const base = this.#options.config.imap.reconnectInterval;
225
+ const delay = Math.min(base * 2 ** this.#reconnectAttempts, 300_000);
226
+ this.#reconnectAttempts += 1;
227
+ this.#logger.warn(formatCompact({
228
+ op: 'imap_reconnect_scheduled',
229
+ endpoint: this.#options.config.id,
230
+ reconnect_ms: delay,
231
+ }));
232
+ this.#reconnectTimer = setTimeout(() => {
233
+ this.#reconnectTimer = null;
234
+ void this.#reconnectImap();
235
+ }, delay);
236
+ }
237
+ async #reconnectImap() {
238
+ if (!this.#started)
239
+ return;
240
+ let imap;
241
+ try {
242
+ const nextImap = this.#options.createImap?.(this.#options.config.imap)
243
+ ?? defaultCreateImap(this.#options.config.imap);
244
+ imap = nextImap;
245
+ this.#imap = nextImap;
246
+ this.#setupImapListeners(nextImap);
247
+ await new Promise((resolve, reject) => {
248
+ nextImap.once('ready', () => {
249
+ void this.#emitPlatformEvent('imap.ready', Object.freeze({ reconnect: true }));
250
+ resolve();
251
+ });
252
+ nextImap.once('error', (error) => reject(error));
253
+ nextImap.connect();
254
+ });
255
+ this.#reconnectAttempts = 0;
256
+ this.#logger.info(formatCompact({
257
+ op: 'imap_reconnect',
258
+ endpoint: this.#options.config.id,
259
+ ok: true,
260
+ }));
261
+ void this.#checkForNewEmails();
262
+ }
263
+ catch (error) {
264
+ this.#logger.warn(formatCompact({
265
+ op: 'imap_reconnect',
266
+ endpoint: this.#options.config.id,
267
+ ok: false,
268
+ error: error instanceof Error ? error.message : String(error),
269
+ }));
270
+ this.#scheduleImapReconnect(imap);
271
+ }
272
+ }
273
+ #startEmailCheck() {
274
+ if (this.#checkTimer)
275
+ return;
276
+ this.#checkTimer = setInterval(() => {
277
+ void this.#checkForNewEmails();
278
+ }, this.#options.config.imap.checkInterval);
279
+ void this.#checkForNewEmails();
280
+ }
281
+ async #checkForNewEmails() {
282
+ if (!this.#imap || !this.#started || this.#checking)
283
+ return;
284
+ // 在飞锁:定时器与 mail 事件可能并发触发,串行化避免重复 admit
285
+ this.#checking = true;
286
+ try {
287
+ await new Promise((resolve, reject) => {
288
+ this.#imap.openBox(this.#options.config.imap.mailbox, false, (error) => {
289
+ if (error)
290
+ return reject(error);
291
+ this.#imap.search(['UNSEEN'], (searchError, results) => {
292
+ if (searchError)
293
+ return reject(searchError);
294
+ if (!results.length)
295
+ return resolve();
296
+ const fetch = this.#imap.fetch(results, {
297
+ bodies: '',
298
+ markSeen: this.#options.config.imap.markSeen,
299
+ });
300
+ fetch.on('message', (msg, seqno) => {
301
+ this.#handleImapMessage(msg, seqno);
302
+ });
303
+ fetch.once('error', (fetchError) => reject(fetchError));
304
+ fetch.once('end', () => resolve());
305
+ });
306
+ });
307
+ });
308
+ }
309
+ catch (error) {
310
+ this.#logger.error('Error checking for new emails:', error);
311
+ }
312
+ finally {
313
+ this.#checking = false;
314
+ }
315
+ }
316
+ #handleImapMessage(msg, _seqno) {
317
+ let body = '';
318
+ let uid = 0;
319
+ msg.on('body', (stream) => {
320
+ stream.on('data', (chunk) => {
321
+ body += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
322
+ });
323
+ });
324
+ msg.once('attributes', (attrs) => {
325
+ uid = attrs.uid ?? 0;
326
+ });
327
+ msg.once('end', () => {
328
+ void simpleParser(body).then((parsed) => {
329
+ this.admit(parseEmailMessage(parsed, uid));
330
+ }).catch((error) => {
331
+ this.#logger.error('Error parsing email:', error);
332
+ });
333
+ });
334
+ }
335
+ }
package/lib/index.d.ts CHANGED
@@ -1,10 +1,4 @@
1
- import { EmailAdapter } from "./adapter.js";
2
- declare module "zhin.js" {
3
- interface Adapters {
4
- email: EmailAdapter;
5
- }
6
- }
7
- export * from "./types.js";
8
- export { EmailBot } from "./bot.js";
9
- export { EmailAdapter } from "./adapter.js";
10
- //# sourceMappingURL=index.d.ts.map
1
+ export { addressListText, emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, htmlToText, parseEmailMessage, resolveEmailConfig, senderDisplayName, type EmailAdapterConfig, type EmailAttachmentsConfig, type EmailMessage, type EmailWireSegment, type ImapConfig, type ResolvedEmailConfig, type SavedEmailAttachment, type SmtpConfig, } from './protocol.js';
2
+ export { EmailClient, emailClient, type EmailClientEventMap, } from './client.js';
3
+ export { EmailEndpoint, type EmailEndpointOptions, } from './endpoint.js';
4
+ export { defaultCreateImap, defaultCreateSmtp, type EmailImapFetchMessage, type EmailImapTransport, type EmailSmtpTransport, } from './transport.js';
package/lib/index.js CHANGED
@@ -1,23 +1,4 @@
1
- /**
2
- * Email 适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin } from "zhin.js";
5
- import { EmailAdapter } from "./adapter.js";
6
- export * from "./types.js";
7
- export { EmailBot } from "./bot.js";
8
- export { EmailAdapter } from "./adapter.js";
9
- const plugin = usePlugin();
10
- const { provide } = plugin;
11
- provide({
12
- name: "email",
13
- description: "Email Bot Adapter",
14
- mounted: async (p) => {
15
- const adapter = new EmailAdapter(p);
16
- await adapter.start();
17
- return adapter;
18
- },
19
- dispose: async (adapter) => {
20
- await adapter.stop();
21
- },
22
- });
23
- //# sourceMappingURL=index.js.map
1
+ export { addressListText, emailInboundConversation, formatInboundContent, formatInboundSegments, formatOutboundMail, htmlToText, parseEmailMessage, resolveEmailConfig, senderDisplayName, } from './protocol.js';
2
+ export { EmailClient, emailClient, } from './client.js';
3
+ export { EmailEndpoint, } from './endpoint.js';
4
+ export { defaultCreateImap, defaultCreateSmtp, } from './transport.js';
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Email SMTP/IMAP helpers (no legacy Adapter/Endpoint / segment-mapper).
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import type { Attachment } from 'mailparser';
6
+ import type { Segment } from '@zhin.js/core/runtime';
7
+ import type { ConversationRef } from '@zhin.js/im-contract';
8
+ export interface SmtpConfig {
9
+ readonly host: string;
10
+ readonly port: number;
11
+ readonly secure: boolean;
12
+ readonly auth: {
13
+ readonly user: string;
14
+ readonly pass: string;
15
+ };
16
+ }
17
+ export interface ImapConfig {
18
+ readonly host: string;
19
+ readonly port: number;
20
+ readonly tls: boolean;
21
+ readonly user: string;
22
+ readonly password: string;
23
+ readonly checkInterval?: number;
24
+ /** IMAP 断线重连基础间隔(指数退避基数),毫秒。 */
25
+ readonly reconnectInterval?: number;
26
+ readonly mailbox?: string;
27
+ readonly markSeen?: boolean;
28
+ }
29
+ export interface EmailAttachmentsConfig {
30
+ readonly enabled: boolean;
31
+ readonly downloadPath?: string;
32
+ readonly maxFileSize?: number;
33
+ readonly allowedTypes?: readonly string[];
34
+ }
35
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
36
+ export interface EmailAdapterConfig {
37
+ readonly id?: string;
38
+ readonly smtp?: SmtpConfig;
39
+ readonly imap?: ImapConfig;
40
+ readonly attachments?: EmailAttachmentsConfig;
41
+ /** Transitional: legacy root `endpoints[]` with `context: email`. */
42
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedEmailConfig> & {
43
+ readonly context?: string;
44
+ }>;
45
+ }
46
+ export interface ResolvedEmailConfig {
47
+ readonly context: 'email';
48
+ readonly id: string;
49
+ readonly smtp: SmtpConfig;
50
+ readonly imap: Required<Pick<ImapConfig, 'checkInterval' | 'reconnectInterval' | 'mailbox' | 'markSeen'>> & ImapConfig;
51
+ readonly attachments?: {
52
+ readonly enabled: boolean;
53
+ readonly downloadPath: string;
54
+ readonly maxFileSize: number;
55
+ readonly allowedTypes?: readonly string[];
56
+ };
57
+ }
58
+ export interface EmailMessage {
59
+ readonly messageId: string;
60
+ readonly from: string;
61
+ readonly to: readonly string[];
62
+ readonly cc?: readonly string[];
63
+ readonly bcc?: readonly string[];
64
+ readonly subject: string;
65
+ readonly text?: string;
66
+ readonly html?: string;
67
+ readonly attachments: readonly Attachment[];
68
+ readonly date: Date;
69
+ readonly uid: number;
70
+ }
71
+ export interface EmailWireSegment {
72
+ readonly type: string;
73
+ readonly data?: Record<string, unknown>;
74
+ }
75
+ export declare function resolveEmailConfig(config?: EmailAdapterConfig): ResolvedEmailConfig;
76
+ export declare function htmlToText(html: string): string;
77
+ export declare function addressListText(addr: unknown): string[];
78
+ export declare function parseEmailMessage(parsed: {
79
+ messageId?: string;
80
+ from?: unknown;
81
+ to?: unknown;
82
+ cc?: unknown;
83
+ bcc?: unknown;
84
+ subject?: string;
85
+ text?: string;
86
+ html?: string | false;
87
+ attachments?: Attachment[];
88
+ date?: Date;
89
+ }, uid: number): EmailMessage;
90
+ /** Build inbound text for OutboundMessageService.receive (gateway owns reply routing). */
91
+ export declare function formatInboundContent(email: EmailMessage): string;
92
+ /** 已落盘的入站附件(attachments.enabled 下载结果)。 */
93
+ export interface SavedEmailAttachment {
94
+ readonly filename: string;
95
+ readonly path: string;
96
+ readonly contentType?: string;
97
+ readonly size?: number;
98
+ }
99
+ /**
100
+ * 入站邮件 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
101
+ * 已落盘附件映射为 image/file 段,MediaRef kind=path 指向下载路径;
102
+ * 未下载的附件(disabled / 被过滤)只保留 content 里的占位文本。
103
+ */
104
+ export declare function formatInboundSegments(email: EmailMessage, savedAttachments?: readonly SavedEmailAttachment[]): Segment[];
105
+ /**
106
+ * 入站归一化 → ConversationRef:Email 无群/频道概念,所有入站邮件都是
107
+ * 与发件人地址的 private 会话(id = 发件人地址)。
108
+ */
109
+ export declare function emailInboundConversation(endpointKey: string, email: EmailMessage): ConversationRef;
110
+ export declare function senderDisplayName(from: string): string;
111
+ /**
112
+ * nodemailer 附件的最小形状:url/path 走 `path`(URL 由 nodemailer 拉流、
113
+ * 本地路径读盘),base64 走 `content` + `encoding: 'base64'` 直发。
114
+ */
115
+ export type EmailOutboundAttachment = {
116
+ filename: string;
117
+ path: string;
118
+ } | {
119
+ filename: string;
120
+ content: string;
121
+ encoding: 'base64';
122
+ };
123
+ /**
124
+ * Wire-encode an already-rendered outbound payload into nodemailer options.
125
+ * Segment canonicalization is intentionally not done here.
126
+ */
127
+ export declare function formatOutboundMail(payload: unknown, options: {
128
+ readonly from: string;
129
+ readonly to: string;
130
+ readonly subject?: string;
131
+ }): {
132
+ from: string;
133
+ to: string;
134
+ subject: string;
135
+ text?: string;
136
+ html?: string;
137
+ attachments?: EmailOutboundAttachment[];
138
+ };