@house-rules/email 0.0.0-stage → 0.1.1
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/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/adapters/console.d.ts +3 -0
- package/dist/adapters/console.js +14 -0
- package/dist/adapters/memory.d.ts +10 -0
- package/dist/adapters/memory.js +29 -0
- package/dist/contract.d.ts +3 -0
- package/dist/contract.js +3 -0
- package/dist/facade.d.ts +16 -0
- package/dist/facade.js +12 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +15 -0
- package/dist/sender/bind-email.d.ts +8 -0
- package/dist/sender/bind-email.js +9 -0
- package/dist/sender/email-sender.d.ts +15 -0
- package/dist/sender/email-sender.js +25 -0
- package/dist/sender/message.d.ts +34 -0
- package/dist/sender/message.js +27 -0
- package/dist/sender/operations.d.ts +17 -0
- package/dist/sender/operations.js +14 -0
- package/dist/sender/permissions.d.ts +4 -0
- package/dist/sender/permissions.js +1 -0
- package/package.json +49 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @house-rules/email: notes for agents
|
|
2
|
+
|
|
3
|
+
## What it does
|
|
4
|
+
|
|
5
|
+
Email sends messages through the `EmailSender` port. It owns no data, migrations, tables or event kinds. It does not depend on event-log or look up actors. Callers supply recipient, content and an idempotency key.
|
|
6
|
+
|
|
7
|
+
## How an app mounts it
|
|
8
|
+
|
|
9
|
+
Mount `EmailMount` with `Email.layer` in composition/modules.ts. Provide exactly one adapter to the facade layer:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
Email.layer.pipe(
|
|
13
|
+
Layer.provide(consoleEmail({ defaultFrom: "noreply@example.test" })),
|
|
14
|
+
)
|
|
15
|
+
// Tests may use memoryEmail({ defaultFrom: "noreply@example.test" }) instead.
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The main door exports `Email`, `bindEmail`, `EmailMount`, `EmailSender` and adapters. The browser-safe `./contract` door exports message/result schemas, errors, `EmailPermissions.send` (`email:send`) and `EmailOperations.send` (`Email.send`). It exports no services, bindings or adapters.
|
|
19
|
+
|
|
20
|
+
`Email.send` takes `{ message, idempotencyKey }` and returns `{ providerId? }`. Only system callers may send. A person is refused with `EmailCallerDenied` (`forbidden`), even with `email:send`. Email has no not-found concept. The operation is not transactional and opens no unit. A delivery feature must pass the same idempotency key on retries; a surrounding database rollback cannot undo a sent email.
|
|
21
|
+
|
|
22
|
+
The facade captures `EmailSender` once. Its `send(message, idempotencyKey)` delegates to `EmailSender.send`, which accepts a flat `EmailDelivery`: `to`, optional `from`, `subject`, `text`, optional `html`, `idempotencyKey` and optional headers. A missing `from` uses the required `defaultFrom`. An explicit blank `from`, blank required text or blank default sender fails with `EmailRejected` (`invalid`). CR/LF is rejected in recipients, sender addresses, subject, and header names and values. Message bodies may contain line breaks.
|
|
23
|
+
|
|
24
|
+
Adapter or provider availability failures use `EmailSendFailed` (`unavailable`) with fixed public text "Email sending is unavailable." Its optional internal `cause` keeps provider diagnostics out of the public message. Never copy those diagnostics into public wording.
|
|
25
|
+
|
|
26
|
+
The console adapter logs only a summary. It never logs addresses, subject, headers, or message bodies. This also prevents disclosure of mandatory security mail bodies. The memory adapter is for tests and exposes `MemoryEmailOutboxTesting`; it keeps the first message for each `idempotencyKey`.
|
|
27
|
+
|
|
28
|
+
A future SMTP or API-provider adapter implements `EmailSender` and is chosen by the app owner. No provider or dependency is selected here. The adapter must pass `idempotencyKey` through when the provider supports it. Plain SMTP has no standard idempotency support, so a retry can duplicate a message.
|
|
29
|
+
|
|
30
|
+
No `migrations.json` entry is needed. The pull-out test removes the email mount, its adapter layer and the app's email delivery edge without changing other modules.
|
|
31
|
+
|
|
32
|
+
## Fixed rules
|
|
33
|
+
|
|
34
|
+
- Keep app-specific content and provider dependencies out of this package's domain boundary.
|
|
35
|
+
- Never log full email bodies or sensitive message metadata.
|
|
36
|
+
- Keep public unavailable messages fixed and provider diagnostics private.
|
|
37
|
+
- No database, migrations, event-log dependency or actor lookup belongs here.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakub Szwajka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, 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,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @house-rules/email
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A provider-neutral email sender port with development console and test memory adapters. It owns no data.
|
|
4
|
+
|
|
5
|
+
Mount `EmailMount` in composition and provide one adapter to the facade:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { consoleEmail, Email } from "@house-rules/email";
|
|
9
|
+
import { Layer } from "effect";
|
|
10
|
+
|
|
11
|
+
const emailLayer = Email.layer.pipe(
|
|
12
|
+
Layer.provide(consoleEmail({ defaultFrom: "noreply@example.test" })),
|
|
13
|
+
);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The browser-safe `@house-rules/email/contract` exports `EmailOperations.send` (`Email.send`) and `EmailPermissions.send` (`email:send`). A feature calls it with `{ message, idempotencyKey }` and receives `{ providerId? }`. The operation accepts system callers only; people receive `EmailCallerDenied`, even if they hold the permission. It writes no unit or data. Retry with the same key because a sent email cannot roll back with a database transaction.
|
|
17
|
+
|
|
18
|
+
`EmailSender.send` takes a flat `EmailDelivery`: recipient, optional sender, subject, text body, optional HTML body, optional headers, and a required idempotency key. `EmailRejected` represents invalid input, including explicit blank senders and CR/LF in addresses, subject or headers. `EmailSendFailed` is an unavailable failure with fixed public wording and an optional internal `cause` for provider diagnostics.
|
|
19
|
+
|
|
20
|
+
Console output contains only a redacted summary. The memory adapter exposes an inspectable test outbox and deduplicates by idempotency key. There is no SMTP or API provider adapter yet. A provider choice and dependency require owner approval. Provider adapters should forward idempotency keys when supported.
|
|
21
|
+
|
|
22
|
+
See [AGENTS.md](./AGENTS.md) for mounting rules and adapter constraints.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { EmailSender, validateEmail } from "../sender/email-sender.js";
|
|
3
|
+
export const console = (options) => Layer.effect(EmailSender, Effect.succeed(EmailSender.of({
|
|
4
|
+
send: (email) => {
|
|
5
|
+
const invalid = validateEmail(email, options.defaultFrom);
|
|
6
|
+
if (invalid)
|
|
7
|
+
return Effect.fail(invalid);
|
|
8
|
+
return Effect.logInfo("Email queued", {
|
|
9
|
+
fromConfigured: Boolean(email.from ?? options.defaultFrom),
|
|
10
|
+
recipientCount: 1,
|
|
11
|
+
hasHtml: Boolean(email.html),
|
|
12
|
+
}).pipe(Effect.as({}));
|
|
13
|
+
},
|
|
14
|
+
})));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { EmailSender, type EmailDelivery, type EmailSenderOptions } from "../sender/email-sender.ts";
|
|
3
|
+
declare const MemoryEmailOutboxTesting_base: Context.ServiceClass<MemoryEmailOutboxTesting, "@house-rules/email/MemoryEmailOutboxTesting", {
|
|
4
|
+
messages: Effect.Effect<ReadonlyArray<EmailDelivery>>;
|
|
5
|
+
add: (email: EmailDelivery) => Effect.Effect<void>;
|
|
6
|
+
}>;
|
|
7
|
+
export declare class MemoryEmailOutboxTesting extends MemoryEmailOutboxTesting_base {
|
|
8
|
+
}
|
|
9
|
+
export declare const memory: (options: EmailSenderOptions) => Layer.Layer<EmailSender | MemoryEmailOutboxTesting, never, never>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Ref } from "effect";
|
|
2
|
+
import { EmailSender, validateEmail, } from "../sender/email-sender.js";
|
|
3
|
+
export class MemoryEmailOutboxTesting extends Context.Service()("@house-rules/email/MemoryEmailOutboxTesting") {
|
|
4
|
+
}
|
|
5
|
+
export const memory = (options) => {
|
|
6
|
+
const outbox = Layer.effect(MemoryEmailOutboxTesting, Effect.gen(function* () {
|
|
7
|
+
const sent = yield* Ref.make(new Map());
|
|
8
|
+
return MemoryEmailOutboxTesting.of({
|
|
9
|
+
messages: Ref.get(sent).pipe(Effect.map((items) => [...items.values()])),
|
|
10
|
+
add: (email) => Ref.update(sent, (items) => {
|
|
11
|
+
if (items.has(email.idempotencyKey))
|
|
12
|
+
return items;
|
|
13
|
+
const message = Object.freeze({ ...email, from: email.from ?? options.defaultFrom });
|
|
14
|
+
return new Map(items).set(email.idempotencyKey, message);
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
}));
|
|
18
|
+
return Layer.effect(EmailSender, Effect.gen(function* () {
|
|
19
|
+
const outbox = yield* MemoryEmailOutboxTesting;
|
|
20
|
+
return EmailSender.of({
|
|
21
|
+
send: (email) => {
|
|
22
|
+
const invalid = validateEmail(email, options.defaultFrom);
|
|
23
|
+
if (invalid)
|
|
24
|
+
return Effect.fail(invalid);
|
|
25
|
+
return outbox.add(email).pipe(Effect.as({}));
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
})).pipe(Layer.provideMerge(outbox));
|
|
29
|
+
};
|
package/dist/contract.js
ADDED
package/dist/facade.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { EmailSender } from "./sender/email-sender.ts";
|
|
3
|
+
import type { EmailMessage, EmailRejected, EmailSendFailed, EmailSendResult } from "./sender/message.ts";
|
|
4
|
+
declare const Email_base: import("effect/Context").ServiceClass<Email, "email", {
|
|
5
|
+
readonly send: (message: EmailMessage, idempotencyKey: string) => Effect.Effect<EmailSendResult, EmailSendFailed | EmailRejected>;
|
|
6
|
+
}> & {
|
|
7
|
+
readonly id: "email";
|
|
8
|
+
} & {
|
|
9
|
+
readonly make: Effect.Effect<{
|
|
10
|
+
readonly send: (message: EmailMessage, idempotencyKey: string) => Effect.Effect<EmailSendResult, EmailSendFailed | EmailRejected>;
|
|
11
|
+
}, never, EmailSender>;
|
|
12
|
+
readonly layer: import("@house-rules/domain").ModuleLayer<Email, never, EmailSender>;
|
|
13
|
+
};
|
|
14
|
+
export declare class Email extends Email_base {
|
|
15
|
+
}
|
|
16
|
+
export {};
|
package/dist/facade.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineModule } from "@house-rules/domain";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { EmailSender } from "./sender/email-sender.js";
|
|
4
|
+
export class Email extends defineModule()("email", {
|
|
5
|
+
make: Effect.gen(function* () {
|
|
6
|
+
const sender = yield* EmailSender;
|
|
7
|
+
return {
|
|
8
|
+
send: (message, idempotencyKey) => sender.send({ ...message, idempotencyKey }),
|
|
9
|
+
};
|
|
10
|
+
}),
|
|
11
|
+
}) {
|
|
12
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Email } from "./facade.ts";
|
|
2
|
+
import { bindEmail } from "./sender/bind-email.ts";
|
|
3
|
+
export declare const EmailMount: {
|
|
4
|
+
map: {
|
|
5
|
+
name: string;
|
|
6
|
+
kind: "package";
|
|
7
|
+
package: string;
|
|
8
|
+
};
|
|
9
|
+
service: typeof Email;
|
|
10
|
+
operations: import("@house-rules/domain/contract").Operation<"Email.send", import("effect/Schema").Struct<{
|
|
11
|
+
readonly message: import("effect/Schema").Struct<{
|
|
12
|
+
readonly to: import("effect/Schema").String;
|
|
13
|
+
readonly from: import("effect/Schema").optionalKey<import("effect/Schema").String>;
|
|
14
|
+
readonly subject: import("effect/Schema").String;
|
|
15
|
+
readonly text: import("effect/Schema").String;
|
|
16
|
+
readonly html: import("effect/Schema").optionalKey<import("effect/Schema").String>;
|
|
17
|
+
readonly headers: import("effect/Schema").optionalKey<import("effect/Schema").$Record<import("effect/Schema").String, import("effect/Schema").String>>;
|
|
18
|
+
}>;
|
|
19
|
+
readonly idempotencyKey: import("effect/Schema").String;
|
|
20
|
+
}>, import("effect/Schema").Struct<{
|
|
21
|
+
readonly providerId: import("effect/Schema").optionalKey<import("effect/Schema").String>;
|
|
22
|
+
}>, import("effect/Schema").Union<readonly [typeof import("./contract.ts").EmailSendFailed, typeof import("./contract.ts").EmailRejected, typeof import("./contract.ts").EmailCallerDenied]>, NoInfer<readonly []>, false, false, false, false>[];
|
|
23
|
+
bind: typeof bindEmail;
|
|
24
|
+
};
|
|
25
|
+
export { Email } from "./facade.ts";
|
|
26
|
+
export { bindEmail } from "./sender/bind-email.ts";
|
|
27
|
+
export { EmailSender } from "./sender/email-sender.ts";
|
|
28
|
+
export type { EmailDelivery, EmailSenderOptions } from "./sender/email-sender.ts";
|
|
29
|
+
export { EmailMessage, EmailOperations, type EmailPermission, EmailPermissions, EmailRejected, EmailSendFailed, EmailCallerDenied, EmailSendResult, } from "./contract.ts";
|
|
30
|
+
export { console as consoleEmail } from "./adapters/console.ts";
|
|
31
|
+
export { memory as memoryEmail, MemoryEmailOutboxTesting } from "./adapters/memory.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Email } from "./facade.js";
|
|
2
|
+
import { bindEmail } from "./sender/bind-email.js";
|
|
3
|
+
import { EmailOperations } from "./sender/operations.js";
|
|
4
|
+
export const EmailMount = {
|
|
5
|
+
map: { name: "email", kind: "package", package: "@house-rules/email" },
|
|
6
|
+
service: Email,
|
|
7
|
+
operations: Object.values(EmailOperations),
|
|
8
|
+
bind: bindEmail,
|
|
9
|
+
};
|
|
10
|
+
export { Email } from "./facade.js";
|
|
11
|
+
export { bindEmail } from "./sender/bind-email.js";
|
|
12
|
+
export { EmailSender } from "./sender/email-sender.js";
|
|
13
|
+
export { EmailMessage, EmailOperations, EmailPermissions, EmailRejected, EmailSendFailed, EmailCallerDenied, EmailSendResult, } from "./contract.js";
|
|
14
|
+
export { console as consoleEmail } from "./adapters/console.js";
|
|
15
|
+
export { memory as memoryEmail, MemoryEmailOutboxTesting } from "./adapters/memory.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Binding } from "@house-rules/application";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { type EmailMessage, type EmailRejected, type EmailSendFailed, type EmailSendResult } from "./message.ts";
|
|
4
|
+
type Service = {
|
|
5
|
+
readonly send: (message: EmailMessage, idempotencyKey: string) => Effect.Effect<EmailSendResult, EmailSendFailed | EmailRejected>;
|
|
6
|
+
};
|
|
7
|
+
export declare const bindEmail: (service: Service) => readonly Binding[];
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { bindOperation } from "@house-rules/application";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { EmailCallerDenied, } from "./message.js";
|
|
4
|
+
import { EmailOperations } from "./operations.js";
|
|
5
|
+
export const bindEmail = (service) => [
|
|
6
|
+
bindOperation(EmailOperations.send, ({ message, idempotencyKey }, { caller }) => caller.kind === "system"
|
|
7
|
+
? service.send(message, idempotencyKey)
|
|
8
|
+
: Effect.fail(new EmailCallerDenied())),
|
|
9
|
+
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Context, type Effect } from "effect";
|
|
2
|
+
import { type EmailMessage, EmailRejected, type EmailSendFailed, type EmailSendResult } from "./message.ts";
|
|
3
|
+
export type EmailDelivery = EmailMessage & Readonly<{
|
|
4
|
+
idempotencyKey: string;
|
|
5
|
+
}>;
|
|
6
|
+
export declare const validateEmail: (email: EmailDelivery, defaultFrom: string) => EmailRejected | undefined;
|
|
7
|
+
declare const EmailSender_base: Context.ServiceClass<EmailSender, "@house-rules/email/EmailSender", {
|
|
8
|
+
send(email: EmailDelivery): Effect.Effect<EmailSendResult, EmailSendFailed | EmailRejected>;
|
|
9
|
+
}>;
|
|
10
|
+
export declare class EmailSender extends EmailSender_base {
|
|
11
|
+
}
|
|
12
|
+
export type EmailSenderOptions = Readonly<{
|
|
13
|
+
defaultFrom: string;
|
|
14
|
+
}>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
import { EmailRejected, } from "./message.js";
|
|
3
|
+
export const validateEmail = (email, defaultFrom) => {
|
|
4
|
+
const headerValues = Object.entries(email.headers ?? {}).flat();
|
|
5
|
+
const headersInvalid = headerValues.some((value) => /[\r\n]/.test(value));
|
|
6
|
+
const addressOrSubjectInvalid = [
|
|
7
|
+
email.to,
|
|
8
|
+
email.from ?? defaultFrom,
|
|
9
|
+
defaultFrom,
|
|
10
|
+
email.subject,
|
|
11
|
+
].some((value) => /[\r\n]/.test(value));
|
|
12
|
+
if (!defaultFrom.trim() ||
|
|
13
|
+
!(email.from ?? defaultFrom).trim() ||
|
|
14
|
+
!email.to.trim() ||
|
|
15
|
+
!email.subject.trim() ||
|
|
16
|
+
!email.text.trim() ||
|
|
17
|
+
!email.idempotencyKey.trim() ||
|
|
18
|
+
headersInvalid ||
|
|
19
|
+
addressOrSubjectInvalid) {
|
|
20
|
+
return new EmailRejected({ message: "Email is invalid." });
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
export class EmailSender extends Context.Service()("@house-rules/email/EmailSender") {
|
|
25
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export declare const EmailMessage: Schema.Struct<{
|
|
3
|
+
readonly to: Schema.String;
|
|
4
|
+
readonly from: Schema.optionalKey<Schema.String>;
|
|
5
|
+
readonly subject: Schema.String;
|
|
6
|
+
readonly text: Schema.String;
|
|
7
|
+
readonly html: Schema.optionalKey<Schema.String>;
|
|
8
|
+
readonly headers: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
|
|
9
|
+
}>;
|
|
10
|
+
export type EmailMessage = typeof EmailMessage.Type;
|
|
11
|
+
export declare const EmailSendResult: Schema.Struct<{
|
|
12
|
+
readonly providerId: Schema.optionalKey<Schema.String>;
|
|
13
|
+
}>;
|
|
14
|
+
export type EmailSendResult = typeof EmailSendResult.Type;
|
|
15
|
+
declare const EmailSendFailed_base: Schema.Class<EmailSendFailed, Schema.TaggedStruct<"EmailSendFailed", {
|
|
16
|
+
readonly kind: Schema.tag<"unavailable">;
|
|
17
|
+
readonly message: Schema.Literal<"Email sending is unavailable.">;
|
|
18
|
+
readonly cause: Schema.optional<Schema.Defect>;
|
|
19
|
+
}>, import("effect/Cause").YieldableError>;
|
|
20
|
+
export declare class EmailSendFailed extends EmailSendFailed_base {
|
|
21
|
+
}
|
|
22
|
+
declare const EmailRejected_base: Schema.Class<EmailRejected, Schema.TaggedStruct<"EmailRejected", {
|
|
23
|
+
readonly kind: Schema.tag<"invalid">;
|
|
24
|
+
readonly message: Schema.String;
|
|
25
|
+
}>, import("effect/Cause").YieldableError>;
|
|
26
|
+
export declare class EmailRejected extends EmailRejected_base {
|
|
27
|
+
}
|
|
28
|
+
declare const EmailCallerDenied_base: Schema.Class<EmailCallerDenied, Schema.TaggedStruct<"EmailCallerDenied", {
|
|
29
|
+
readonly kind: Schema.tag<"forbidden">;
|
|
30
|
+
readonly message: Schema.tag<"Email sending requires a system caller.">;
|
|
31
|
+
}>, import("effect/Cause").YieldableError>;
|
|
32
|
+
export declare class EmailCallerDenied extends EmailCallerDenied_base {
|
|
33
|
+
}
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { errorKind } from "@house-rules/domain/contract";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
export const EmailMessage = Schema.Struct({
|
|
4
|
+
to: Schema.String,
|
|
5
|
+
from: Schema.optionalKey(Schema.String),
|
|
6
|
+
subject: Schema.String,
|
|
7
|
+
text: Schema.String,
|
|
8
|
+
html: Schema.optionalKey(Schema.String),
|
|
9
|
+
headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
10
|
+
});
|
|
11
|
+
export const EmailSendResult = Schema.Struct({ providerId: Schema.optionalKey(Schema.String) });
|
|
12
|
+
export class EmailSendFailed extends Schema.TaggedError()("EmailSendFailed", {
|
|
13
|
+
kind: errorKind("unavailable"),
|
|
14
|
+
message: Schema.Literal("Email sending is unavailable."),
|
|
15
|
+
cause: Schema.optional(Schema.Defect()),
|
|
16
|
+
}) {
|
|
17
|
+
}
|
|
18
|
+
export class EmailRejected extends Schema.TaggedError()("EmailRejected", {
|
|
19
|
+
kind: errorKind("invalid"),
|
|
20
|
+
message: Schema.String,
|
|
21
|
+
}) {
|
|
22
|
+
}
|
|
23
|
+
export class EmailCallerDenied extends Schema.TaggedError()("EmailCallerDenied", {
|
|
24
|
+
kind: errorKind("forbidden"),
|
|
25
|
+
message: Schema.tag("Email sending requires a system caller."),
|
|
26
|
+
}) {
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { EmailRejected, EmailSendFailed, EmailCallerDenied } from "./message.ts";
|
|
3
|
+
export declare const EmailOperations: {
|
|
4
|
+
readonly send: import("@house-rules/domain/contract").Operation<"Email.send", Schema.Struct<{
|
|
5
|
+
readonly message: Schema.Struct<{
|
|
6
|
+
readonly to: Schema.String;
|
|
7
|
+
readonly from: Schema.optionalKey<Schema.String>;
|
|
8
|
+
readonly subject: Schema.String;
|
|
9
|
+
readonly text: Schema.String;
|
|
10
|
+
readonly html: Schema.optionalKey<Schema.String>;
|
|
11
|
+
readonly headers: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
|
|
12
|
+
}>;
|
|
13
|
+
readonly idempotencyKey: Schema.String;
|
|
14
|
+
}>, Schema.Struct<{
|
|
15
|
+
readonly providerId: Schema.optionalKey<Schema.String>;
|
|
16
|
+
}>, Schema.Union<readonly [typeof EmailSendFailed, typeof EmailRejected, typeof EmailCallerDenied]>, NoInfer<readonly []>, false, false, false, false>;
|
|
17
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineOperation } from "@house-rules/domain/contract";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
import { EmailMessage, EmailRejected, EmailSendFailed, EmailCallerDenied, EmailSendResult, } from "./message.js";
|
|
4
|
+
import { EmailPermissions } from "./permissions.js";
|
|
5
|
+
const send = defineOperation("Email.send", {
|
|
6
|
+
description: "Send an email as the system with a delivery idempotency key.",
|
|
7
|
+
input: Schema.Struct({ message: EmailMessage, idempotencyKey: Schema.String }),
|
|
8
|
+
output: EmailSendResult,
|
|
9
|
+
failure: Schema.Union([EmailSendFailed, EmailRejected, EmailCallerDenied]),
|
|
10
|
+
permission: EmailPermissions.send,
|
|
11
|
+
edges: [],
|
|
12
|
+
transactional: false,
|
|
13
|
+
});
|
|
14
|
+
export const EmailOperations = { send };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EmailPermissions = { send: "email:send" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@house-rules/email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A provider-neutral email sending port with console and memory adapters.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"AGENTS.md",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./contract": {
|
|
18
|
+
"types": "./dist/contract.d.ts",
|
|
19
|
+
"default": "./dist/contract.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/JakubSzwajka/house-rules.git",
|
|
28
|
+
"directory": "packages/email"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/JakubSzwajka/house-rules/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/JakubSzwajka/house-rules",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@house-rules/application": "0.3.0",
|
|
36
|
+
"@house-rules/domain": "0.2.1",
|
|
37
|
+
"effect": "4.0.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@effect/vitest": "4.0.3",
|
|
41
|
+
"@house-rules/application": "0.3.0",
|
|
42
|
+
"@house-rules/domain": "0.2.1",
|
|
43
|
+
"effect": "4.0.3",
|
|
44
|
+
"typescript": "7.0.2",
|
|
45
|
+
"vitest": "5.0.1"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc --project tsconfig.build.json",
|
|
49
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
50
|
+
"test": "varlock run --path ../../ -- vitest run"
|
|
51
|
+
}
|
|
6
52
|
}
|