Skip to content

Commit 64778e5

Browse files
committed
🏗️ Cloudflare MailChannel → Resend
1 parent 525270a commit 64778e5

File tree

12 files changed

+195
-183
lines changed

12 files changed

+195
-183
lines changed

.github/workflows/build.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,15 @@ jobs:
6565
environment: ${{ env.MODE }}
6666
secrets: |
6767
MODE
68-
API_MAIL_DKIM_PRIVATE_KEY
68+
API_RESEND_API_KEY
6969
API_DISCORD_WEBHOOK_URL_CONTACT
7070
API_DISCORD_WEBHOOK_MENTION_ID
7171
API_CONTACT_MANIFEST
7272
API_ASSETS_ENDPOINT
7373
API_ASSETS_ACCESS_KEY
7474
env:
7575
MODE: ${{ env.MODE }}
76-
API_MAIL_DKIM_PRIVATE_KEY: ${{ secrets.API_MAIL_DKIM_PRIVATE_KEY }}
76+
API_RESEND_API_KEY: ${{ secrets.API_RESEND_API_KEY }}
7777
API_DISCORD_WEBHOOK_URL_CONTACT: ${{ secrets.API_DISCORD_WEBHOOK_URL_CONTACT }}
7878
API_DISCORD_WEBHOOK_MENTION_ID: ${{ secrets.API_DISCORD_WEBHOOK_MENTION_ID }}
7979
API_CONTACT_MANIFEST: ${{ secrets.API_CONTACT_MANIFEST }}

api/.dev.vars.example

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
MODE = "xxxxxxxxxxxxxxxxx"
2-
API_MAIL_DKIM_PRIVATE_KEY = "xxxxxxxxxxxxxxxxx"
2+
API_RESEND_API_KEY = "xxxxxxxxxxxxxxxxx"
33
API_DISCORD_WEBHOOK_URL_CONTACT = "xxxxxxxxxxxxxxxxx"
44
API_DISCORD_WEBHOOK_MENTION_ID = "xxxxxxxxxxxxxxxxx"
55
API_CONTACT_MANIFEST = `

api/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
"deploy": "wrangler deploy --minify src/index.ts"
77
},
88
"dependencies": {
9-
"@cloudflare/pages-plugin-mailchannels": "^0.1.2",
109
"@hono/zod-validator": "^0.2.0",
1110
"hono": "^4.0.9",
11+
"neverthrow": "^7.0.0",
12+
"resend": "^3.5.0",
13+
"ts-pattern": "^5.2.0",
1214
"zod": "^3.22.4"
1315
},
1416
"devDependencies": {

api/src/api/v1/contact/index.ts

+78-98
Original file line numberDiff line numberDiff line change
@@ -1,131 +1,97 @@
11
/* eslint-disable no-console */
22
import { getModeName, type ENV, type HonoType } from "@api/lib/consts";
33
import { authGuard, configureCors } from "@api/lib/middlewares/contact";
4-
import { type EmailAddress, getPersonalizationInfo } from "@api/lib/sender";
4+
import { sendEmail } from "@api/lib/sender";
55
import { INFO } from "@client/lib/config";
66
import { formatDate, getEntries } from "@client/lib/consts";
77
import {
88
type ContactFormData,
99
formSchema,
1010
zContactFormData,
1111
} from "@client/lib/services/contact";
12-
import { sendEmail } from "@cloudflare/pages-plugin-mailchannels/api";
1312
import { zValidator } from "@hono/zod-validator";
1413
import { Hono } from "hono";
1514
import { cors } from "hono/cors";
1615
import { HTTPException } from "hono/http-exception";
16+
import { err, ok, ResultAsync } from "neverthrow";
1717

18-
async function sendAdminMail(
18+
function formatContactData(data: ContactFormData) {
19+
return getEntries(formSchema)
20+
.filter(([k]) => data[k] !== "")
21+
.map(([key, { description }]) =>
22+
`
23+
${description}:
24+
${data[key]}
25+
`.trim(),
26+
)
27+
.join("\n");
28+
}
29+
30+
function sendAdminMail(
1931
data: ContactFormData,
2032
env: ENV,
21-
acceptDate: Date,
33+
acceptedDate: Date,
2234
): ReturnType<typeof sendEmail> {
23-
const to: EmailAddress = {
24-
email: INFO.addr.email.contact,
25-
name: INFO.name.full,
26-
};
27-
const from: EmailAddress = {
28-
email: INFO.addr.email.noreply,
29-
name: INFO.name.full,
30-
};
3135
const subject = `【お問い合わせ】${data.name} 様からのお問い合わせ - ${INFO.id}`;
3236

33-
const _data = getEntries(formSchema)
34-
.map(([key, { description }]) =>
35-
`
36-
${description}:
37-
${data[key]}
38-
`.trim(),
39-
)
40-
.join("\n");
4137
const content = `
4238
${getModeName(env.MODE)}
4339
ポートフォリオのお問い合わせフォームから以下の内容が送信されました。
4440
お問い合わせ内容を確認し、返信をお願いします。
4541
4642
--- お問い合わせ内容 ---
47-
${_data}
43+
${formatContactData(data)}
4844
---
49-
受付日時: ${formatDate(acceptDate, "YYYY-MM-DD HH:mm:ss")}
45+
受付日時: ${formatDate(acceptedDate, "YYYY-MM-DD HH:mm:ss")}
5046
`.trim();
5147

52-
return await sendEmail({
53-
personalizations: [
54-
getPersonalizationInfo({
55-
env,
56-
info: { to: [to], from },
57-
}),
58-
],
59-
content: [
60-
{
61-
type: "text/plain",
62-
value: content,
63-
},
64-
],
48+
return sendEmail(env, {
49+
to: INFO.addr.email.admin,
6550
subject,
66-
from,
51+
text: content,
6752
});
6853
}
6954

70-
async function sendThanksMail(
55+
function sendThanksMail(
7156
data: ContactFormData,
7257
env: ENV,
7358
acceptDate: Date,
7459
): ReturnType<typeof sendEmail> {
75-
const to: EmailAddress = {
76-
email: data.email,
77-
name: `${data.name} 様`,
78-
};
79-
const from: EmailAddress = {
80-
email: INFO.addr.email.noreply,
81-
name: INFO.name.full,
82-
};
8360
const subject = `【自動返信】お問い合わせありがとうございます - ${INFO.name.full}`;
8461

85-
const _data = getEntries(formSchema)
86-
.map(([key, { description }]) =>
87-
`
88-
${description}:
89-
${data[key]}
90-
`.trim(),
91-
)
92-
.join("\n");
62+
const deadlineDate = new Date(acceptDate);
63+
deadlineDate.setDate(acceptDate.getDate() + 3);
64+
const deadlineDateStr = formatDate(deadlineDate, "YYYY年M月d日");
9365

9466
const content = `
9567
${getModeName(env.MODE)}
96-
テストテストテストテストテストテスト
97-
テストテストテストテストテストテスト
68+
この度は、お問い合わせいただき誠にありがとうございます。
69+
以下の内容でお問い合わせを受け付けいたしました。
9870
9971
--- お問い合わせ内容 ---
100-
${_data}
72+
${formatContactData(data)}
10173
---
10274
103-
テストテストテストテストテストテスト
75+
もしも${deadlineDateStr}までに返信がない場合、お手数ですが${INFO.addr.email.contact}まで再度ご連絡いただきますようお願いいたします。
76+
77+
※このメールは自動返信により送信しています。ご返信をいただいても対応できかねますことをご了承ください。
10478
`.trim();
10579

106-
return await sendEmail({
107-
personalizations: [
108-
getPersonalizationInfo({
109-
env,
110-
info: { to: [to], from },
111-
}),
112-
],
113-
content: [
114-
{
115-
type: "text/plain",
116-
value: content,
117-
},
118-
],
80+
return sendEmail(env, {
81+
to: data.email,
11982
subject,
120-
from,
83+
text: content,
12184
});
12285
}
12386

124-
async function sendDiscordWebhook(
87+
function sendDiscordWebhook(
12588
data: ContactFormData,
12689
env: ENV,
12790
acceptDate: Date,
128-
): Promise<Response> {
91+
): ResultAsync<
92+
undefined,
93+
{ code: "NETWORK_ERROR" | "API_ERROR"; details: string }
94+
> {
12995
const unixTime = Math.floor(acceptDate.getTime() / 1000);
13096
const content = `
13197
<:9u3rcusdark:1204434658792837160> <@${env.API_DISCORD_WEBHOOK_MENTION_ID}>
@@ -156,12 +122,25 @@ async function sendDiscordWebhook(
156122
attachments: [],
157123
};
158124

159-
return await fetch(env.API_DISCORD_WEBHOOK_URL_CONTACT, {
160-
method: "POST",
161-
headers: {
162-
"Content-Type": "application/json",
163-
},
164-
body: JSON.stringify(body),
125+
return ResultAsync.fromPromise(
126+
fetch(env.API_DISCORD_WEBHOOK_URL_CONTACT, {
127+
method: "POST",
128+
headers: {
129+
"Content-Type": "application/json",
130+
},
131+
body: JSON.stringify(body),
132+
}),
133+
(e) => ({ code: "NETWORK_ERROR", details: String(e) }) as const,
134+
).andThen((res) => {
135+
if (!res.ok) {
136+
console.error("Failed to send discord webhook", res);
137+
return err({
138+
code: "API_ERROR",
139+
details: `API returned status: ${res.statusText}`,
140+
} as const);
141+
}
142+
143+
return ok(undefined);
165144
});
166145
}
167146

@@ -170,33 +149,34 @@ export const contact = new Hono<HonoType>()
170149
.use("*", authGuard, configureCors)
171150
.post("/", zValidator("json", zContactFormData), async (ctx) => {
172151
const data = ctx.req.valid("json");
173-
const acceptDate = new Date();
152+
const acceptedDate = new Date();
174153

175-
if (ctx.env.MODE !== "local") {
176-
const adminMailResult = await sendAdminMail(data, ctx.env, acceptDate);
177-
if (!adminMailResult.success) {
178-
console.warn("Failed to send admin mail", adminMailResult.errors);
154+
await sendAdminMail(data, ctx.env, acceptedDate).match(
155+
() => {},
156+
() => {
179157
throw new HTTPException(500, {
180158
message: "Failed to send admin mail",
181159
});
182-
}
160+
},
161+
);
183162

184-
const autoReplyResult = await sendThanksMail(data, ctx.env, acceptDate);
185-
if (!autoReplyResult.success) {
186-
console.warn("Failed to send auto thanks mail", autoReplyResult.errors);
163+
await sendThanksMail(data, ctx.env, acceptedDate).match(
164+
() => {},
165+
() => {
187166
throw new HTTPException(500, {
188167
message: "Failed to send thanks mail",
189168
});
190-
}
191-
}
169+
},
170+
);
192171

193-
const res = await sendDiscordWebhook(data, ctx.env, acceptDate);
194-
if (!res.ok) {
195-
console.warn("Failed to send discord webhook", res);
196-
throw new HTTPException(500, {
197-
message: "Failed to send discord webhook",
198-
});
199-
}
172+
await sendDiscordWebhook(data, ctx.env, acceptedDate).match(
173+
() => {},
174+
() => {
175+
throw new HTTPException(500, {
176+
message: "Failed to send discord webhook",
177+
});
178+
},
179+
);
200180

201-
return ctx.json({ acceptDate }, 201);
181+
return ctx.json({ acceptedDate }, 201);
202182
});

api/src/lib/consts.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type ContactManifest, zContactManifest } from "./types/contact";
22

33
export type ENV = {
44
MODE: "local" | "preview" | "production";
5-
API_MAIL_DKIM_PRIVATE_KEY: string;
5+
API_RESEND_API_KEY: string;
66
API_DISCORD_WEBHOOK_URL_CONTACT: string;
77
API_DISCORD_WEBHOOK_MENTION_ID: string;
88
API_CONTACT_MANIFEST: string;

api/src/lib/sender.ts

+45-22
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,50 @@
1-
import { type Personalization } from "@cloudflare/pages-plugin-mailchannels/api";
1+
/* eslint-disable no-console */
2+
import { INFO } from "@client/lib/config";
3+
import { err, ok, ResultAsync } from "neverthrow";
4+
import {
5+
type CreateEmailOptions,
6+
type CreateEmailResponseSuccess,
7+
Resend,
8+
} from "resend";
29
import { type ENV } from "./consts";
10+
import { type Override } from "./types/utils";
311

4-
export type EmailAddress = {
5-
email: string;
6-
name?: string;
7-
};
8-
9-
export function getPersonalizationInfo({
10-
env,
11-
info,
12-
}: {
13-
env: ENV;
14-
info: Personalization;
15-
}): Personalization {
16-
const { API_MAIL_DKIM_PRIVATE_KEY } = env;
17-
if (API_MAIL_DKIM_PRIVATE_KEY == null) {
18-
throw new Error("MAIL_DKIM_PRIVATE_KEY is not set");
12+
export function sendEmail(
13+
env: ENV,
14+
body: Override<
15+
CreateEmailOptions,
16+
{
17+
from?: never;
18+
text: string;
19+
}
20+
>,
21+
): ResultAsync<
22+
CreateEmailResponseSuccess,
23+
{
24+
code: "API_ERROR";
25+
err: Error;
26+
}
27+
> {
28+
if (env.API_RESEND_API_KEY == null) {
29+
throw new Error("API_RESEND_API_KEY is not set!");
1930
}
2031

21-
return {
22-
...info,
23-
dkim_domain: "9u3rc.us",
24-
dkim_selector: "mailchannels",
25-
dkim_private_key: API_MAIL_DKIM_PRIVATE_KEY,
26-
};
32+
const resend = new Resend(env.API_RESEND_API_KEY);
33+
34+
return ResultAsync.fromSafePromise(
35+
resend.emails.send({
36+
...body,
37+
from: `${INFO.name.full} <${INFO.addr.email.noreply}>`,
38+
}),
39+
).andThen((res) => {
40+
if (res.data == null) {
41+
console.warn("Failed to send email", res.error?.name, res.error?.message);
42+
return err({
43+
code: "API_ERROR",
44+
err: new Error(res.error?.message),
45+
} as const);
46+
}
47+
48+
return ok(res.data);
49+
});
2750
}

api/src/lib/types/utils.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type Override<T, U extends { [Key in keyof T]?: unknown }> = Omit<
2+
T,
3+
keyof U
4+
> &
5+
U;
6+
7+
export type Entries<T> = Array<
8+
keyof T extends infer U ? (U extends keyof T ? [U, T[U]] : never) : never
9+
>;
10+
11+
export type ArrayElem<ArrayType extends readonly unknown[]> =
12+
ArrayType extends ReadonlyArray<infer ElementType> ? ElementType : never;
13+
14+
export type OmitStrict<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
15+
16+
export type Nullable<T> = T | null | undefined;

0 commit comments

Comments
 (0)