@stacksjs/buddy 0.70.88 → 0.70.91
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/dist/cli.d.ts +1 -0
- package/dist/cli.js +165 -0
- package/dist/commands/about.d.ts +2 -0
- package/dist/commands/about.js +35 -0
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.js +33 -0
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +68 -0
- package/dist/commands/build.d.ts +2 -0
- package/dist/commands/build.js +153 -0
- package/dist/commands/cd.d.ts +20 -0
- package/dist/commands/cd.js +39 -0
- package/dist/commands/changelog.d.ts +2 -0
- package/dist/commands/changelog.js +32 -0
- package/dist/commands/clean.d.ts +2 -0
- package/dist/commands/clean.js +37 -0
- package/dist/commands/cloud.d.ts +2 -0
- package/dist/commands/cloud.js +503 -0
- package/dist/commands/commit.d.ts +2 -0
- package/dist/commands/commit.js +16 -0
- package/dist/commands/completion.d.ts +2 -0
- package/dist/commands/completion.js +143 -0
- package/dist/commands/config-migrate.d.ts +26 -0
- package/dist/commands/config-migrate.js +94 -0
- package/dist/commands/configure.d.ts +2 -0
- package/dist/commands/configure.js +48 -0
- package/dist/commands/create.d.ts +2 -0
- package/dist/commands/create.js +141 -0
- package/dist/commands/deploy.d.ts +107 -0
- package/dist/commands/deploy.js +1492 -0
- package/dist/commands/dev.d.ts +10 -0
- package/dist/commands/dev.js +915 -0
- package/dist/commands/dns.d.ts +2 -0
- package/dist/commands/dns.js +102 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +285 -0
- package/dist/commands/domains.d.ts +2 -0
- package/dist/commands/domains.js +159 -0
- package/dist/commands/email.d.ts +2 -0
- package/dist/commands/email.js +524 -0
- package/dist/commands/env.d.ts +2 -0
- package/dist/commands/env.js +246 -0
- package/dist/commands/extension.d.ts +8 -0
- package/dist/commands/extension.js +78 -0
- package/dist/commands/features.d.ts +180 -0
- package/dist/commands/features.js +422 -0
- package/dist/commands/fresh.d.ts +2 -0
- package/dist/commands/fresh.js +39 -0
- package/dist/commands/generate.d.ts +2 -0
- package/dist/commands/generate.js +104 -0
- package/dist/commands/http.d.ts +2 -0
- package/dist/commands/http.js +33 -0
- package/dist/commands/index.d.ts +53 -0
- package/dist/commands/index.js +53 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.js +22 -0
- package/dist/commands/key.d.ts +2 -0
- package/dist/commands/key.js +23 -0
- package/dist/commands/lint.d.ts +2 -0
- package/dist/commands/lint.js +46 -0
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.js +108 -0
- package/dist/commands/mail.d.ts +4 -0
- package/dist/commands/mail.js +752 -0
- package/dist/commands/maintenance.d.ts +2 -0
- package/dist/commands/maintenance.js +141 -0
- package/dist/commands/make.d.ts +2 -0
- package/dist/commands/make.js +375 -0
- package/dist/commands/migrate-project.d.ts +2 -0
- package/dist/commands/migrate-project.js +49 -0
- package/dist/commands/migrate.d.ts +4 -0
- package/dist/commands/migrate.js +455 -0
- package/dist/commands/outdated.d.ts +2 -0
- package/dist/commands/outdated.js +19 -0
- package/dist/commands/package.d.ts +2 -0
- package/dist/commands/package.js +17 -0
- package/dist/commands/phone.d.ts +2 -0
- package/dist/commands/phone.js +188 -0
- package/dist/commands/ports.d.ts +2 -0
- package/dist/commands/ports.js +118 -0
- package/dist/commands/prepublish.d.ts +2 -0
- package/dist/commands/prepublish.js +17 -0
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +29 -0
- package/dist/commands/publish.d.ts +2 -0
- package/dist/commands/publish.js +172 -0
- package/dist/commands/queue.d.ts +2 -0
- package/dist/commands/queue.js +248 -0
- package/dist/commands/release.d.ts +2 -0
- package/dist/commands/release.js +30 -0
- package/dist/commands/route.d.ts +2 -0
- package/dist/commands/route.js +21 -0
- package/dist/commands/saas.d.ts +2 -0
- package/dist/commands/saas.js +25 -0
- package/dist/commands/schedule.d.ts +2 -0
- package/dist/commands/schedule.js +61 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +84 -0
- package/dist/commands/seed.d.ts +2 -0
- package/dist/commands/seed.js +71 -0
- package/dist/commands/serve.d.ts +25 -0
- package/dist/commands/serve.js +182 -0
- package/dist/commands/setup.d.ts +12 -0
- package/dist/commands/setup.js +215 -0
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +214 -0
- package/dist/commands/sms.d.ts +2 -0
- package/dist/commands/sms.js +328 -0
- package/dist/commands/stacks.d.ts +2 -0
- package/dist/commands/stacks.js +69 -0
- package/dist/commands/telemetry.d.ts +2 -0
- package/dist/commands/telemetry.js +74 -0
- package/dist/commands/test.d.ts +2 -0
- package/dist/commands/test.js +130 -0
- package/dist/commands/tinker.d.ts +2 -0
- package/dist/commands/tinker.js +37 -0
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands/types.js +18 -0
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +98 -0
- package/dist/commands/version.d.ts +2 -0
- package/dist/commands/version.js +16 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +223 -0
- package/dist/custom-cli.d.ts +1 -0
- package/dist/custom-cli.js +23 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lazy-commands.d.ts +61 -0
- package/dist/lazy-commands.js +188 -0
- package/dist/migrators/index.d.ts +9 -0
- package/dist/migrators/index.js +62 -0
- package/dist/migrators/laravel/index.d.ts +2 -0
- package/dist/migrators/laravel/index.js +148 -0
- package/dist/migrators/laravel/migrations.d.ts +52 -0
- package/dist/migrators/laravel/migrations.js +232 -0
- package/dist/migrators/laravel/models.d.ts +34 -0
- package/dist/migrators/laravel/models.js +134 -0
- package/dist/migrators/rails/index.d.ts +2 -0
- package/dist/migrators/rails/index.js +11 -0
- package/dist/migrators/types.d.ts +44 -0
- package/dist/migrators/types.js +0 -0
- package/package.json +2 -2
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { log, runCommand } from "@stacksjs/cli";
|
|
2
|
+
import { ExitCode } from "@stacksjs/types";
|
|
3
|
+
import { getErrorMessage } from "@stacksjs/utils";
|
|
4
|
+
import { CLI } from "@stacksjs/clapp";
|
|
5
|
+
import { createHash, createHmac, randomBytes } from "crypto";
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
export function resolveDirectMailHost(env = process.env) {
|
|
9
|
+
let configured = env.MAIL_SERVER_HOST || (env.HCLOUD_TOKEN || env.HETZNER_API_TOKEN ? env.MAIL_HOST : void 0);
|
|
10
|
+
if (configured && ["127.0.0.1", "localhost", "mailpit"].includes(configured))
|
|
11
|
+
configured = env.MAIL_DOMAIN ? `mail.${env.MAIL_DOMAIN}` : void 0;
|
|
12
|
+
if (!configured || configured === "127.0.0.1" || configured === "localhost")
|
|
13
|
+
return;
|
|
14
|
+
return configured.replace(/^https?:\/\//, "").split("/")[0]?.split(":")[0];
|
|
15
|
+
}
|
|
16
|
+
export function sanitizeLineCount(value) {
|
|
17
|
+
const count = Number.parseInt(value || "50", 10);
|
|
18
|
+
return String(Number.isFinite(count) ? Math.min(5000, Math.max(1, count)) : 50);
|
|
19
|
+
}
|
|
20
|
+
function shellQuote(value) {
|
|
21
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
22
|
+
}
|
|
23
|
+
async function resolveOperationalMailHost() {
|
|
24
|
+
if (process.env.MAIL_SERVER_HOST)
|
|
25
|
+
return resolveDirectMailHost();
|
|
26
|
+
const token = process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
|
|
27
|
+
if (!token)
|
|
28
|
+
return;
|
|
29
|
+
const appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
30
|
+
try {
|
|
31
|
+
const ip = (await (await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(`${appName}-production-app`)}`, {
|
|
32
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
33
|
+
})).json()).servers?.[0]?.public_net?.ipv4?.ip;
|
|
34
|
+
if (ip)
|
|
35
|
+
return ip;
|
|
36
|
+
} catch {}
|
|
37
|
+
const configured = resolveDirectMailHost();
|
|
38
|
+
if (configured)
|
|
39
|
+
return configured;
|
|
40
|
+
const { email } = await import("@stacksjs/config");
|
|
41
|
+
return email?.domain ? `mail.${email.domain}` : void 0;
|
|
42
|
+
}
|
|
43
|
+
export function mailCommands(buddy) {
|
|
44
|
+
buddy.command("mail:provision", "Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>", "Mail server IP (defaults to the A record of config.email.domain)").action(async (options) => {
|
|
45
|
+
const { email: emailConfig } = await import("@stacksjs/config"), domain = emailConfig?.domain;
|
|
46
|
+
if (!domain) {
|
|
47
|
+
log.error("config/email.ts has no `domain` \u2014 set it (e.g. domain: 'bughq.org') and add `mailboxes`.");
|
|
48
|
+
process.exit(ExitCode.FatalError);
|
|
49
|
+
}
|
|
50
|
+
let ip = options.ip;
|
|
51
|
+
if (!ip)
|
|
52
|
+
try {
|
|
53
|
+
ip = (await (await import("node:dns")).promises.resolve4(domain))[0];
|
|
54
|
+
} catch {
|
|
55
|
+
log.error(`Could not resolve an IP for ${domain}. Deploy the site first, or pass --ip <mail-server-ip>.`);
|
|
56
|
+
process.exit(ExitCode.FatalError);
|
|
57
|
+
}
|
|
58
|
+
const { provisionMailTenant, reconcileMailDns } = await import("./deploy");
|
|
59
|
+
log.info(`Provisioning mail for ${domain} on ${ip}...`);
|
|
60
|
+
const res = await provisionMailTenant(ip, log);
|
|
61
|
+
if (res)
|
|
62
|
+
await reconcileMailDns(res, ip, log);
|
|
63
|
+
log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);
|
|
64
|
+
process.exit(ExitCode.Success);
|
|
65
|
+
});
|
|
66
|
+
buddy.command("mail:user:add <email>", "Add a mail user").option("--password <password>", "User password (generated if not provided)").action(async (email, options) => {
|
|
67
|
+
log.info(`Adding mail user: ${email}`);
|
|
68
|
+
try {
|
|
69
|
+
const password = options.password || randomBytes(16).toString("base64").replace(/[+/=]/g, "").substring(0, 16), passwordHash = createHash("sha256").update(password).digest("hex"), tableName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-mail-users`, { DynamoDBClient } = await import("@stacksjs/ts-cloud");
|
|
70
|
+
await new DynamoDBClient(process.env.AWS_REGION || "us-east-1").putItem({
|
|
71
|
+
TableName: tableName,
|
|
72
|
+
Item: {
|
|
73
|
+
email: { S: email.toLowerCase() },
|
|
74
|
+
passwordHash: { S: passwordHash },
|
|
75
|
+
createdAt: { S: new Date().toISOString() }
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
log.success(`User ${email} added successfully!`);
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
81
|
+
console.log(" MAIL CREDENTIALS");
|
|
82
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
83
|
+
console.log("");
|
|
84
|
+
console.log(` Email: ${email}`);
|
|
85
|
+
console.log(` Password: ${password}`);
|
|
86
|
+
console.log("");
|
|
87
|
+
console.log(" To connect with Mail.app, run:");
|
|
88
|
+
console.log(" buddy mail:proxy");
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
91
|
+
console.log("");
|
|
92
|
+
if (!options.password)
|
|
93
|
+
log.warn("Save this password! It will not be shown again.");
|
|
94
|
+
process.exit(ExitCode.Success);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
log.error(`Failed to add user: ${getErrorMessage(error)}`);
|
|
97
|
+
process.exit(ExitCode.FatalError);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
buddy.command("mail:user:list", "List mail users").action(async () => {
|
|
101
|
+
try {
|
|
102
|
+
const tableName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-mail-users`, { DynamoDBClient } = await import("@stacksjs/ts-cloud"), result = await new DynamoDBClient(process.env.AWS_REGION || "us-east-1").scan({ TableName: tableName });
|
|
103
|
+
console.log("");
|
|
104
|
+
console.log("Mail Users:");
|
|
105
|
+
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
106
|
+
if (!result.Items || result.Items.length === 0)
|
|
107
|
+
console.log(" No users found");
|
|
108
|
+
else
|
|
109
|
+
for (const item of result.Items) {
|
|
110
|
+
const email = item.email?.S || "unknown", createdAt = item.createdAt?.S || "unknown";
|
|
111
|
+
console.log(` \uD83D\uDCE7 ${email}`);
|
|
112
|
+
console.log(` Created: ${createdAt}`);
|
|
113
|
+
}
|
|
114
|
+
console.log("");
|
|
115
|
+
process.exit(ExitCode.Success);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
log.error(`Failed to list users: ${getErrorMessage(error)}`);
|
|
118
|
+
process.exit(ExitCode.FatalError);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
buddy.command("mail:user:delete <email>", "Delete a mail user").action(async (email) => {
|
|
122
|
+
try {
|
|
123
|
+
const tableName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-mail-users`, { DynamoDBClient } = await import("@stacksjs/ts-cloud");
|
|
124
|
+
await new DynamoDBClient(process.env.AWS_REGION || "us-east-1").deleteItem({
|
|
125
|
+
TableName: tableName,
|
|
126
|
+
Key: { email: { S: email.toLowerCase() } }
|
|
127
|
+
});
|
|
128
|
+
log.success(`User ${email} deleted`);
|
|
129
|
+
process.exit(ExitCode.Success);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
log.error(`Failed to delete user: ${getErrorMessage(error)}`);
|
|
132
|
+
process.exit(ExitCode.FatalError);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
buddy.command("mail:proxy", "Start local IMAP proxy for Mail.app").option("--port <port>", "IMAP proxy port", { default: "1993" }).option("--api <url>", "Mail API URL").action(async (options) => {
|
|
136
|
+
log.info("Starting IMAP proxy...");
|
|
137
|
+
try {
|
|
138
|
+
let apiUrl = options.api;
|
|
139
|
+
if (!apiUrl) {
|
|
140
|
+
const { AWSCloudFormationClient } = await import("@stacksjs/ts-cloud"), cfn = new AWSCloudFormationClient(process.env.AWS_REGION || "us-east-1"), stackName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-cloud`;
|
|
141
|
+
try {
|
|
142
|
+
apiUrl = ((await cfn.describeStacks({ stackName })).Stacks?.[0]?.Outputs || []).find((o) => o.OutputKey === "MailApiUrl")?.OutputValue;
|
|
143
|
+
} catch (e) {}
|
|
144
|
+
}
|
|
145
|
+
if (!apiUrl) {
|
|
146
|
+
log.error("Mail API URL not found. Run `buddy deploy` first to create the mail infrastructure.");
|
|
147
|
+
process.exit(ExitCode.FatalError);
|
|
148
|
+
}
|
|
149
|
+
console.log("");
|
|
150
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
151
|
+
console.log(" IMAP PROXY");
|
|
152
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
153
|
+
console.log("");
|
|
154
|
+
console.log(` API URL: ${apiUrl}`);
|
|
155
|
+
console.log(` Port: ${options.port}`);
|
|
156
|
+
console.log("");
|
|
157
|
+
console.log(" Configure Mail.app with:");
|
|
158
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
159
|
+
console.log(" Account Type: IMAP");
|
|
160
|
+
console.log(" Incoming Server: localhost");
|
|
161
|
+
console.log(` Incoming Port: ${options.port}`);
|
|
162
|
+
console.log(" SSL: No (local connection)");
|
|
163
|
+
console.log(" Outgoing Server: localhost");
|
|
164
|
+
console.log(` Outgoing Port: ${options.port}`);
|
|
165
|
+
console.log(" Username: your-email@stacksjs.com");
|
|
166
|
+
console.log(" Password: your-password");
|
|
167
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
168
|
+
console.log("");
|
|
169
|
+
process.env.MAIL_API_URL = apiUrl;
|
|
170
|
+
process.env.IMAP_PORT = options.port;
|
|
171
|
+
const { ImapProxy } = await import("../../mail-server/src/proxy/imap-proxy"), proxy = new ImapProxy(Number.parseInt(options.port, 10), apiUrl);
|
|
172
|
+
await proxy.start();
|
|
173
|
+
process.on("SIGINT", () => {
|
|
174
|
+
console.log(`
|
|
175
|
+
Shutting down proxy...`);
|
|
176
|
+
proxy.stop();
|
|
177
|
+
process.exit(0);
|
|
178
|
+
});
|
|
179
|
+
} catch (error) {
|
|
180
|
+
log.error(`Failed to start proxy: ${getErrorMessage(error)}`);
|
|
181
|
+
process.exit(ExitCode.FatalError);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
buddy.command("mail:test", "Test mail API connection").action(async () => {
|
|
185
|
+
try {
|
|
186
|
+
const { AWSCloudFormationClient } = await import("@stacksjs/ts-cloud"), cfn = new AWSCloudFormationClient(process.env.AWS_REGION || "us-east-1"), stackName = `${(process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-")}-cloud`, mailApiOutput = ((await cfn.describeStacks({ stackName })).Stacks?.[0]?.Outputs || []).find((o) => o.OutputKey === "MailApiUrl");
|
|
187
|
+
if (!mailApiOutput?.OutputValue) {
|
|
188
|
+
log.error("Mail API not deployed. Run `buddy deploy` first.");
|
|
189
|
+
process.exit(ExitCode.FatalError);
|
|
190
|
+
}
|
|
191
|
+
const apiUrl = mailApiOutput.OutputValue;
|
|
192
|
+
log.info(`Testing Mail API at ${apiUrl}`);
|
|
193
|
+
const response = await fetch(`${apiUrl}/mailboxes`, {
|
|
194
|
+
headers: {
|
|
195
|
+
Authorization: `Basic ${Buffer.from("test@stacksjs.com:test").toString("base64")}`
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (response.status === 401)
|
|
199
|
+
log.success("Mail API is responding (auth required)");
|
|
200
|
+
else if (response.ok) {
|
|
201
|
+
log.success("Mail API is working!");
|
|
202
|
+
const data = await response.json();
|
|
203
|
+
console.log("Response:", JSON.stringify(data, null, 2));
|
|
204
|
+
} else
|
|
205
|
+
log.warn(`Mail API returned status ${response.status}`);
|
|
206
|
+
process.exit(ExitCode.Success);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
log.error(`Failed to test API: ${getErrorMessage(error)}`);
|
|
209
|
+
process.exit(ExitCode.FatalError);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
buddy.command("mail:credentials [email]", "Show SMTP credentials for email access").action(async (emailAddress) => {
|
|
213
|
+
loadEnvFiles();
|
|
214
|
+
const domain = process.env.APP_DOMAIN || process.env.MAIL_DOMAIN || "stacksjs.com", mailHost = `mail.${domain}`, email = emailAddress || `chris@${domain}`, username = email.includes("@") ? email.split("@")[0] : email, envKey = `MAIL_PASSWORD_${username.toUpperCase()}`, password = process.env[envKey];
|
|
215
|
+
console.log("");
|
|
216
|
+
console.log("==========================================================");
|
|
217
|
+
console.log(` EMAIL CREDENTIALS \u2014 ${email}`);
|
|
218
|
+
console.log("==========================================================");
|
|
219
|
+
console.log("");
|
|
220
|
+
console.log(` Email: ${email}`);
|
|
221
|
+
console.log(` Host: ${mailHost}`);
|
|
222
|
+
console.log(" Port: 587");
|
|
223
|
+
console.log(" Security: STARTTLS");
|
|
224
|
+
console.log(` Username: ${username}`);
|
|
225
|
+
if (password)
|
|
226
|
+
console.log(` Password: ${password}`);
|
|
227
|
+
else
|
|
228
|
+
console.log(` Password: (not set \u2014 add ${envKey} to .env.production)`);
|
|
229
|
+
console.log("");
|
|
230
|
+
console.log("==========================================================");
|
|
231
|
+
console.log("");
|
|
232
|
+
process.exit(ExitCode.Success);
|
|
233
|
+
});
|
|
234
|
+
buddy.command("mail:logs", "Show mail server logs from production").option("-n, --lines <count>", "Number of log lines to show", { default: "50" }).option("-f, --follow", "Follow log output (poll every 5s)").option("--filter <pattern>", "Filter logs by pattern (e.g. AUTH, LOGIN, error)").action(async (options) => {
|
|
235
|
+
loadEnvFiles();
|
|
236
|
+
const directHost = await resolveOperationalMailHost();
|
|
237
|
+
if (directHost) {
|
|
238
|
+
const lines = sanitizeLineCount(options.lines), filter = options.filter ? options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g, "") : "", command = `journalctl -u mail --no-pager -n ${lines}${filter ? ` | grep -iE ${shellQuote(filter)}` : ""}`;
|
|
239
|
+
try {
|
|
240
|
+
const output = execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`, { encoding: "utf8" });
|
|
241
|
+
console.log(output);
|
|
242
|
+
process.exit(ExitCode.Success);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
log.error(`Failed to fetch logs from ${directHost}: ${getErrorMessage(error)}`);
|
|
245
|
+
process.exit(ExitCode.FatalError);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
249
|
+
try {
|
|
250
|
+
const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region);
|
|
251
|
+
log.info("Finding mail server...");
|
|
252
|
+
const instanceId = (await ec2.describeInstances({
|
|
253
|
+
Filters: [
|
|
254
|
+
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
255
|
+
{ Name: "instance-state-name", Values: ["running"] }
|
|
256
|
+
]
|
|
257
|
+
})).Reservations?.[0]?.Instances?.[0]?.InstanceId;
|
|
258
|
+
if (!instanceId) {
|
|
259
|
+
log.error("No running mail server found.");
|
|
260
|
+
log.info(`Looked for instances tagged: ${appName}-mail-server`);
|
|
261
|
+
process.exit(ExitCode.FatalError);
|
|
262
|
+
}
|
|
263
|
+
log.info(`Found instance: ${instanceId}`);
|
|
264
|
+
const lines = sanitizeLineCount(options.lines), sanitizedFilter = options.filter ? options.filter.replace(/[^a-zA-Z0-9_.@\s-]/g, "") : "", filterCmd = sanitizedFilter ? ` | grep -iE '${sanitizedFilter}'` : "", fetchLogs = async () => {
|
|
265
|
+
const cmd = `journalctl -u smtp-server -u mail-server --no-pager -n ${lines} --since "10 minutes ago" 2>&1${filterCmd}`, result = await ssm.runShellCommand(instanceId, [cmd], {
|
|
266
|
+
timeoutSeconds: 30,
|
|
267
|
+
maxWaitMs: 30000,
|
|
268
|
+
pollIntervalMs: 2000
|
|
269
|
+
});
|
|
270
|
+
if (!result.success) {
|
|
271
|
+
log.error(`Failed to fetch logs: ${result.error || "Unknown error"}`);
|
|
272
|
+
return !1;
|
|
273
|
+
}
|
|
274
|
+
if (result.output) {
|
|
275
|
+
const logLines = result.output.trim().split(`
|
|
276
|
+
`);
|
|
277
|
+
for (const line of logLines)
|
|
278
|
+
if (line.includes("error") || line.includes("Error") || line.includes("AUTH failed"))
|
|
279
|
+
console.log(` \x1B[31m${line}\x1B[0m`);
|
|
280
|
+
else if (line.includes("warning") || line.includes("Warning"))
|
|
281
|
+
console.log(` \x1B[33m${line}\x1B[0m`);
|
|
282
|
+
else if (line.includes("AUTH success") || line.includes("LOGIN completed") || line.includes("authenticated"))
|
|
283
|
+
console.log(` \x1B[32m${line}\x1B[0m`);
|
|
284
|
+
else
|
|
285
|
+
console.log(` ${line}`);
|
|
286
|
+
} else
|
|
287
|
+
console.log(" No log entries found.");
|
|
288
|
+
return !0;
|
|
289
|
+
};
|
|
290
|
+
console.log("");
|
|
291
|
+
console.log("==========================================================");
|
|
292
|
+
console.log(" MAIL SERVER LOGS");
|
|
293
|
+
console.log("==========================================================");
|
|
294
|
+
console.log("");
|
|
295
|
+
await fetchLogs();
|
|
296
|
+
if (options.follow) {
|
|
297
|
+
console.log("");
|
|
298
|
+
log.info("Following logs (Ctrl+C to stop)...");
|
|
299
|
+
let polling = !1;
|
|
300
|
+
const poll = setInterval(() => {
|
|
301
|
+
if (polling)
|
|
302
|
+
return;
|
|
303
|
+
polling = !0;
|
|
304
|
+
console.log("");
|
|
305
|
+
fetchLogs().catch((e) => log.error("Log polling failed:", e)).finally(() => {
|
|
306
|
+
polling = !1;
|
|
307
|
+
});
|
|
308
|
+
}, 5000);
|
|
309
|
+
await new Promise((resolve) => {
|
|
310
|
+
process.on("SIGINT", () => {
|
|
311
|
+
clearInterval(poll);
|
|
312
|
+
resolve();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
console.log("");
|
|
317
|
+
process.exit(ExitCode.Success);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
log.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
|
|
320
|
+
process.exit(ExitCode.FatalError);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
buddy.command("mail:status", "Show mail server status").action(async () => {
|
|
324
|
+
loadEnvFiles();
|
|
325
|
+
const directHost = await resolveOperationalMailHost();
|
|
326
|
+
if (directHost) {
|
|
327
|
+
const command = 'systemctl is-active mail; systemctl show mail -p NRestarts -p ActiveEnterTimestamp --value; ss -H -ltn | grep -E "(:(25|143|465|587|993))\\b"; systemctl is-enabled mail-health.timer; systemctl is-active mail-health.timer';
|
|
328
|
+
try {
|
|
329
|
+
const output = execSync(`ssh -o BatchMode=yes -o ConnectTimeout=10 root@${shellQuote(directHost)} ${shellQuote(command)}`, { encoding: "utf8" });
|
|
330
|
+
console.log(output);
|
|
331
|
+
process.exit(ExitCode.Success);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
log.error(`Mail server ${directHost} is unhealthy: ${getErrorMessage(error)}`);
|
|
334
|
+
process.exit(ExitCode.FatalError);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
338
|
+
try {
|
|
339
|
+
const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region), instanceId = (await ec2.describeInstances({
|
|
340
|
+
Filters: [
|
|
341
|
+
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
342
|
+
{ Name: "instance-state-name", Values: ["running"] }
|
|
343
|
+
]
|
|
344
|
+
})).Reservations?.[0]?.Instances?.[0]?.InstanceId;
|
|
345
|
+
if (!instanceId) {
|
|
346
|
+
log.error("No running mail server found.");
|
|
347
|
+
log.info(`Looked for instances tagged: ${appName}-mail-server`);
|
|
348
|
+
process.exit(ExitCode.FatalError);
|
|
349
|
+
}
|
|
350
|
+
const result = await ssm.runShellCommand(instanceId, [
|
|
351
|
+
"echo === Service ===",
|
|
352
|
+
"(systemctl status smtp-server --no-pager 2>&1 || systemctl status mail-server --no-pager 2>&1) | head -15",
|
|
353
|
+
"echo === Ports ===",
|
|
354
|
+
'ss -tlnp 2>&1 | grep -E "(25|587|465|993|143|110|995)" || echo "No mail ports listening"',
|
|
355
|
+
"echo === Memory ===",
|
|
356
|
+
`ps aux | grep -E "(smtp-server|bun|mail)" | grep -v grep | awk '{print $6/1024 "MB", $11}'`
|
|
357
|
+
], {
|
|
358
|
+
timeoutSeconds: 30,
|
|
359
|
+
maxWaitMs: 30000,
|
|
360
|
+
pollIntervalMs: 2000
|
|
361
|
+
});
|
|
362
|
+
console.log("");
|
|
363
|
+
console.log("==========================================================");
|
|
364
|
+
console.log(" MAIL SERVER STATUS");
|
|
365
|
+
console.log("==========================================================");
|
|
366
|
+
console.log("");
|
|
367
|
+
if (result.success && result.output)
|
|
368
|
+
for (const line of result.output.trim().split(`
|
|
369
|
+
`))
|
|
370
|
+
console.log(` ${line}`);
|
|
371
|
+
else
|
|
372
|
+
log.error(`Failed: ${result.error}`);
|
|
373
|
+
console.log("");
|
|
374
|
+
process.exit(ExitCode.Success);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
log.error(`Failed to get status: ${getErrorMessage(error)}`);
|
|
377
|
+
process.exit(ExitCode.FatalError);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
buddy.command("mail:port25:request", "Request port 25 unblock for direct SMTP delivery").option("--provider <provider>", "Cloud provider: aws or hetzner", { default: "aws" }).option("--instance-id <id>", "AWS EC2 instance ID (auto-detected if not provided)").option("--server-id <id>", "Hetzner server ID (auto-detected if not provided)").option("--elastic-ip <ip>", "AWS Elastic IP address").option("--rdns <hostname>", "Reverse DNS hostname (e.g. mail.stacksjs.com)").option("--use-case <text>", "Description of email use case").action(async (options) => {
|
|
381
|
+
loadEnvFiles();
|
|
382
|
+
const domain = process.env.APP_DOMAIN || process.env.MAIL_DOMAIN || "stacksjs.com", provider = options.provider.toLowerCase();
|
|
383
|
+
if (provider === "aws")
|
|
384
|
+
await requestAwsPort25(options, domain);
|
|
385
|
+
else if (provider === "hetzner")
|
|
386
|
+
await requestHetznerPort25(options, domain);
|
|
387
|
+
else {
|
|
388
|
+
log.error(`Unknown provider: ${provider}. Use 'aws' or 'hetzner'.`);
|
|
389
|
+
process.exit(ExitCode.FatalError);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
buddy.command("mail:port25:status", "Check outbound port 25 status on the mail server").option("--provider <provider>", "Cloud provider: aws or hetzner", { default: "aws" }).action(async (options) => {
|
|
393
|
+
loadEnvFiles();
|
|
394
|
+
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
395
|
+
if (options.provider === "hetzner") {
|
|
396
|
+
const hetznerToken = process.env.HETZNER_API_TOKEN;
|
|
397
|
+
if (!hetznerToken) {
|
|
398
|
+
log.error("HETZNER_API_TOKEN not set");
|
|
399
|
+
process.exit(ExitCode.FatalError);
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const ip = (await (await fetch("https://api.hetzner.cloud/v1/servers?label_selector=service=mail", {
|
|
403
|
+
headers: { Authorization: `Bearer ${hetznerToken}` }
|
|
404
|
+
})).json()).servers?.[0]?.public_net?.ipv4?.ip;
|
|
405
|
+
if (!ip) {
|
|
406
|
+
const mailServer = (await (await fetch("https://api.hetzner.cloud/v1/servers", {
|
|
407
|
+
headers: { Authorization: `Bearer ${hetznerToken}` }
|
|
408
|
+
})).json()).servers?.find((s) => s.name?.includes("mail"));
|
|
409
|
+
if (mailServer) {
|
|
410
|
+
const serverIp = mailServer.public_net?.ipv4?.ip;
|
|
411
|
+
log.info(`Found Hetzner server: ${mailServer.name} (${serverIp})`);
|
|
412
|
+
const result = execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${serverIp} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`, { encoding: "utf-8" });
|
|
413
|
+
printPort25Status(result.trim(), "Hetzner", serverIp);
|
|
414
|
+
} else
|
|
415
|
+
log.error("No mail server found on Hetzner");
|
|
416
|
+
} else {
|
|
417
|
+
const result = execSync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@${ip} "timeout 5 bash -c 'echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25' 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED"`, { encoding: "utf-8" });
|
|
418
|
+
printPort25Status(result.trim(), "Hetzner", ip);
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
log.error(`Failed: ${getErrorMessage(error)}`);
|
|
422
|
+
}
|
|
423
|
+
process.exit(ExitCode.Success);
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const { EC2Client, SSMClient } = await import("@stacksjs/ts-cloud"), ec2 = new EC2Client(region), ssm = new SSMClient(region), instance = (await ec2.describeInstances({
|
|
427
|
+
Filters: [
|
|
428
|
+
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
429
|
+
{ Name: "instance-state-name", Values: ["running"] }
|
|
430
|
+
]
|
|
431
|
+
})).Reservations?.[0]?.Instances?.[0], instanceId = instance?.InstanceId;
|
|
432
|
+
if (!instanceId) {
|
|
433
|
+
log.error("No running mail server found.");
|
|
434
|
+
process.exit(ExitCode.FatalError);
|
|
435
|
+
}
|
|
436
|
+
const publicIp = instance?.PublicIpAddress || "unknown";
|
|
437
|
+
log.info(`Testing port 25 on ${instanceId} (${publicIp})...`);
|
|
438
|
+
const result = await ssm.runShellCommand(instanceId, [
|
|
439
|
+
'timeout 5 bash -c "echo QUIT > /dev/tcp/gmail-smtp-in.l.google.com/25" 2>&1 && echo PORT_25_OPEN || echo PORT_25_BLOCKED'
|
|
440
|
+
], { timeoutSeconds: 15, maxWaitMs: 20000, pollIntervalMs: 2000 });
|
|
441
|
+
printPort25Status(result.output?.trim() || "PORT_25_BLOCKED", "AWS", publicIp);
|
|
442
|
+
process.exit(ExitCode.Success);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
log.error(`Failed: ${getErrorMessage(error)}`);
|
|
445
|
+
process.exit(ExitCode.FatalError);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
buddy.command("mail:server", "Start SMTP relay server").option("--port <port>", "SMTP port", { default: "587" }).action(async (options) => {
|
|
449
|
+
loadEnvFiles();
|
|
450
|
+
const domain = process.env.APP_DOMAIN || process.env.MAIL_DOMAIN || "stacksjs.com", port = Number.parseInt(options.port, 10), mailboxes = ["chris", "blake", "glenn"], users = {}, generated = [];
|
|
451
|
+
for (const name of mailboxes) {
|
|
452
|
+
const envKey = `MAIL_PASSWORD_${name.toUpperCase()}`;
|
|
453
|
+
let password = process.env[envKey];
|
|
454
|
+
if (!password) {
|
|
455
|
+
password = randomBytes(16).toString("base64url");
|
|
456
|
+
generated.push(`${envKey}=${password}`);
|
|
457
|
+
}
|
|
458
|
+
users[name] = { password, email: `${name}@${domain}` };
|
|
459
|
+
}
|
|
460
|
+
if (generated.length > 0) {
|
|
461
|
+
console.log("");
|
|
462
|
+
log.warn("Generated missing passwords. Add these to .env.production:");
|
|
463
|
+
for (const line of generated)
|
|
464
|
+
console.log(` ${line}`);
|
|
465
|
+
}
|
|
466
|
+
console.log("");
|
|
467
|
+
console.log("==========================================================");
|
|
468
|
+
console.log(" SMTP RELAY SERVER");
|
|
469
|
+
console.log("==========================================================");
|
|
470
|
+
console.log("");
|
|
471
|
+
console.log(` Host: mail.${domain}`);
|
|
472
|
+
console.log(` Port: ${port}`);
|
|
473
|
+
console.log(` Domain: ${domain}`);
|
|
474
|
+
console.log("");
|
|
475
|
+
console.log(" Mailboxes:");
|
|
476
|
+
for (const [name, user] of Object.entries(users))
|
|
477
|
+
console.log(` ${name} (${user.email})`);
|
|
478
|
+
console.log("");
|
|
479
|
+
console.log(" Relaying through AWS SES");
|
|
480
|
+
console.log("==========================================================");
|
|
481
|
+
console.log("");
|
|
482
|
+
try {
|
|
483
|
+
const { SmtpServer } = await import("../../../cloud/src/imap/smtp-server"), server = new SmtpServer({
|
|
484
|
+
port,
|
|
485
|
+
host: "0.0.0.0",
|
|
486
|
+
domain,
|
|
487
|
+
users,
|
|
488
|
+
sentBucket: "stacks-production-email",
|
|
489
|
+
sentPrefix: "sent/"
|
|
490
|
+
});
|
|
491
|
+
await server.start();
|
|
492
|
+
await new Promise((resolve) => {
|
|
493
|
+
process.on("SIGINT", async () => {
|
|
494
|
+
console.log(`
|
|
495
|
+
Shutting down SMTP server...`);
|
|
496
|
+
await server.stop();
|
|
497
|
+
resolve();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
process.exit(0);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
log.error(`Failed to start SMTP server: ${getErrorMessage(error)}`);
|
|
503
|
+
process.exit(ExitCode.FatalError);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
buddy.command("mail:preview", "Open the dev-only Mailable preview UI in your browser").option("--no-open", "Print the URL but skip the browser-open step", { default: !1 }).option("--port <port>", "Override the API port (default: from config.ports.api / PORT_API)").example("buddy mail:preview").example("buddy mail:preview --no-open").example("buddy mail:preview --port 4000").action(async (options) => {
|
|
507
|
+
const { config } = await import("@stacksjs/config"), portFromOption = options.port ? Number(options.port) : void 0, portFromEnv = process.env.PORT_API ? Number(process.env.PORT_API) : void 0, url = `http://localhost:${portFromOption || portFromEnv || config.ports?.api || 3008}/_stacks/mail/preview`;
|
|
508
|
+
log.info(`Mailable preview: ${url}`);
|
|
509
|
+
log.info("(Make sure `./buddy dev:api` is running.)");
|
|
510
|
+
if (options.open !== !1)
|
|
511
|
+
try {
|
|
512
|
+
execSync(`open "${url}" 2>/dev/null || xdg-open "${url}" 2>/dev/null || true`, { encoding: "utf-8" });
|
|
513
|
+
} catch {}
|
|
514
|
+
process.exit(ExitCode.Success);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function loadEnvFiles() {
|
|
518
|
+
const envPath = ".env.production";
|
|
519
|
+
if (existsSync(envPath)) {
|
|
520
|
+
const content = readFileSync(envPath, "utf-8");
|
|
521
|
+
for (const line of content.split(`
|
|
522
|
+
`)) {
|
|
523
|
+
const eq = line.indexOf("=");
|
|
524
|
+
if (eq > 0 && !line.startsWith("#")) {
|
|
525
|
+
const key = line.slice(0, eq).trim(), value = line.slice(eq + 1).trim();
|
|
526
|
+
if (!process.env[key])
|
|
527
|
+
process.env[key] = value;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
|
|
532
|
+
const awsCredsPath = `${process.env.HOME || process.env.USERPROFILE || ""}/.aws/credentials`;
|
|
533
|
+
if (existsSync(awsCredsPath)) {
|
|
534
|
+
const credsContent = readFileSync(awsCredsPath, "utf-8"), profile = process.env.AWS_PROFILE || "default", lines = credsContent.split(`
|
|
535
|
+
`);
|
|
536
|
+
let inProfile = !1;
|
|
537
|
+
for (const line of lines) {
|
|
538
|
+
const trimmed = line.trim();
|
|
539
|
+
if (trimmed.startsWith("[")) {
|
|
540
|
+
inProfile = trimmed === `[${profile}]`;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (inProfile && trimmed.includes("=")) {
|
|
544
|
+
const eq = trimmed.indexOf("="), key = trimmed.slice(0, eq).trim(), value = trimmed.slice(eq + 1).trim();
|
|
545
|
+
if (key === "aws_access_key_id")
|
|
546
|
+
process.env.AWS_ACCESS_KEY_ID = value;
|
|
547
|
+
if (key === "aws_secret_access_key")
|
|
548
|
+
process.env.AWS_SECRET_ACCESS_KEY = value;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function printPort25Status(result, provider, ip) {
|
|
555
|
+
console.log("");
|
|
556
|
+
console.log("==========================================================");
|
|
557
|
+
console.log(" PORT 25 STATUS");
|
|
558
|
+
console.log("==========================================================");
|
|
559
|
+
console.log("");
|
|
560
|
+
console.log(` Provider: ${provider}`);
|
|
561
|
+
console.log(` Server IP: ${ip}`);
|
|
562
|
+
if (result.includes("PORT_25_OPEN")) {
|
|
563
|
+
console.log(" Port 25: \x1B[32mOPEN\x1B[0m");
|
|
564
|
+
console.log("");
|
|
565
|
+
console.log(" Direct SMTP delivery is available!");
|
|
566
|
+
console.log(" Set SMTP_DELIVERY_METHOD=direct in your server env.");
|
|
567
|
+
} else {
|
|
568
|
+
console.log(" Port 25: \x1B[31mBLOCKED\x1B[0m");
|
|
569
|
+
console.log("");
|
|
570
|
+
console.log(` Run: buddy mail:port25:request --provider ${provider.toLowerCase()}`);
|
|
571
|
+
}
|
|
572
|
+
console.log("");
|
|
573
|
+
console.log("==========================================================");
|
|
574
|
+
console.log("");
|
|
575
|
+
}
|
|
576
|
+
async function requestAwsPort25(options, domain) {
|
|
577
|
+
const region = process.env.AWS_REGION || "us-east-1", appName = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
578
|
+
let { instanceId, elasticIp } = options;
|
|
579
|
+
if (!instanceId || !elasticIp)
|
|
580
|
+
try {
|
|
581
|
+
const { EC2Client } = await import("@stacksjs/ts-cloud"), instance = (await new EC2Client(region).describeInstances({
|
|
582
|
+
Filters: [
|
|
583
|
+
{ Name: "tag:Name", Values: [`${appName}-mail-server`] },
|
|
584
|
+
{ Name: "instance-state-name", Values: ["running"] }
|
|
585
|
+
]
|
|
586
|
+
})).Reservations?.[0]?.Instances?.[0];
|
|
587
|
+
if (instance) {
|
|
588
|
+
instanceId = instanceId || instance.InstanceId;
|
|
589
|
+
elasticIp = elasticIp || instance.PublicIpAddress;
|
|
590
|
+
}
|
|
591
|
+
} catch {}
|
|
592
|
+
const rdns = options.rdns || `mail.${domain}`, useCase = options.useCase || [
|
|
593
|
+
`We run a self-hosted mail server (mail.${domain}) for our organization.`,
|
|
594
|
+
"We send transactional emails (account notifications, password resets) and",
|
|
595
|
+
"occasional team communication. Expected volume: <1000 emails/day.",
|
|
596
|
+
"We have SPF, DKIM, and DMARC configured. We need outbound port 25",
|
|
597
|
+
"to deliver email directly to recipient mail servers via MX records."
|
|
598
|
+
].join(" ");
|
|
599
|
+
console.log("");
|
|
600
|
+
console.log("==========================================================");
|
|
601
|
+
console.log(" AWS PORT 25 UNBLOCK REQUEST");
|
|
602
|
+
console.log("==========================================================");
|
|
603
|
+
console.log("");
|
|
604
|
+
console.log(` Instance: ${instanceId || "(not detected)"}`);
|
|
605
|
+
console.log(` Elastic IP: ${elasticIp || "(not detected)"}`);
|
|
606
|
+
console.log(` Reverse DNS: ${rdns}`);
|
|
607
|
+
console.log(` Region: ${region}`);
|
|
608
|
+
console.log("");
|
|
609
|
+
try {
|
|
610
|
+
const result = execSync(`aws support create-case --subject "Request to remove email sending limitations" --communication-body "${useCase}
|
|
611
|
+
|
|
612
|
+
Elastic IP: ${elasticIp || "N/A"}
|
|
613
|
+
Instance ID: ${instanceId || "N/A"}
|
|
614
|
+
Reverse DNS: ${rdns}
|
|
615
|
+
Region: ${region}" --service-code "amazon-ec2" --category-code "port-25-throttle" --severity-code "normal" --cc-email-addresses "" --language "en" --region us-east-1 2>&1`, { encoding: "utf-8" });
|
|
616
|
+
if (result.includes("caseId"))
|
|
617
|
+
try {
|
|
618
|
+
const caseId = JSON.parse(result).caseId;
|
|
619
|
+
log.success(`Support case created: ${caseId}`);
|
|
620
|
+
log.info("");
|
|
621
|
+
log.info(" AWS will process this within 24-48 hours.");
|
|
622
|
+
log.info(" Check status: aws support describe-cases --region us-east-1");
|
|
623
|
+
log.info("");
|
|
624
|
+
process.exit(ExitCode.Success);
|
|
625
|
+
} catch {
|
|
626
|
+
log.error("Failed to parse AWS support case response");
|
|
627
|
+
}
|
|
628
|
+
} catch {}
|
|
629
|
+
if (elasticIp) {
|
|
630
|
+
log.info("Setting up reverse DNS (rDNS) for Elastic IP...");
|
|
631
|
+
try {
|
|
632
|
+
const allocResult = execSync(`aws ec2 describe-addresses --public-ips ${elasticIp} --region ${region} --query 'Addresses[0].AllocationId' --output text 2>&1`, { encoding: "utf-8" }).trim();
|
|
633
|
+
if (allocResult && !allocResult.includes("error")) {
|
|
634
|
+
execSync(`aws ec2 modify-address-attribute --allocation-id ${allocResult} --domain-name ${rdns} --region ${region} 2>&1`, { encoding: "utf-8" });
|
|
635
|
+
log.success(`Reverse DNS requested: ${elasticIp} -> ${rdns}`);
|
|
636
|
+
console.log(" (rDNS propagation may take a few minutes)");
|
|
637
|
+
}
|
|
638
|
+
} catch (error) {
|
|
639
|
+
log.warn(`rDNS setup failed: ${getErrorMessage(error)}`);
|
|
640
|
+
console.log(" You may need to set this up manually in the AWS console.");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
console.log("");
|
|
644
|
+
console.log(" The Support API requires a Business or Enterprise support plan.");
|
|
645
|
+
console.log(" Opening the AWS web form instead...");
|
|
646
|
+
console.log("");
|
|
647
|
+
console.log(" Fill in these details:");
|
|
648
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
649
|
+
console.log(` Email address: admin@${domain}`);
|
|
650
|
+
console.log(" Use case: Sending outbound email from self-hosted mail server");
|
|
651
|
+
console.log(` Elastic IP: ${elasticIp || "(enter your Elastic IP)"}`);
|
|
652
|
+
console.log(` rDNS: ${rdns}`);
|
|
653
|
+
console.log(` Region: ${region}`);
|
|
654
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
655
|
+
console.log("");
|
|
656
|
+
const formUrl = "https://console.aws.amazon.com/support/contacts#/rdns-limits";
|
|
657
|
+
try {
|
|
658
|
+
execSync(`open "${formUrl}" 2>/dev/null || xdg-open "${formUrl}" 2>/dev/null || echo ""`, { encoding: "utf-8" });
|
|
659
|
+
log.info(`Opened: ${formUrl}`);
|
|
660
|
+
} catch {
|
|
661
|
+
console.log(" Open this URL in your browser:");
|
|
662
|
+
console.log(` ${formUrl}`);
|
|
663
|
+
}
|
|
664
|
+
console.log("");
|
|
665
|
+
console.log(" After AWS approves (24-48 hours), verify with:");
|
|
666
|
+
console.log(" buddy mail:port25:status");
|
|
667
|
+
console.log("");
|
|
668
|
+
process.exit(ExitCode.Success);
|
|
669
|
+
}
|
|
670
|
+
async function requestHetznerPort25(options, domain) {
|
|
671
|
+
const hetznerToken = process.env.HETZNER_API_TOKEN;
|
|
672
|
+
if (!hetznerToken) {
|
|
673
|
+
log.error("HETZNER_API_TOKEN not set. Add it to .env or set it as an environment variable.");
|
|
674
|
+
process.exit(ExitCode.FatalError);
|
|
675
|
+
}
|
|
676
|
+
let serverId = options.serverId, serverIp = "", serverName = "";
|
|
677
|
+
try {
|
|
678
|
+
const data = await (await fetch("https://api.hetzner.cloud/v1/servers", {
|
|
679
|
+
headers: { Authorization: `Bearer ${hetznerToken}` }
|
|
680
|
+
})).json(), mailServer = data.servers?.find((s) => s.name?.includes("mail")) || data.servers?.[0];
|
|
681
|
+
if (mailServer) {
|
|
682
|
+
serverId = serverId || String(mailServer.id);
|
|
683
|
+
serverIp = mailServer.public_net?.ipv4?.ip || "";
|
|
684
|
+
serverName = mailServer.name || "";
|
|
685
|
+
}
|
|
686
|
+
} catch {}
|
|
687
|
+
const rdns = options.rdns || `mail.${domain}`, useCase = options.useCase || [
|
|
688
|
+
`Self-hosted mail server for ${domain}.`,
|
|
689
|
+
"Transactional + team email, <1000 emails/day.",
|
|
690
|
+
"SPF/DKIM/DMARC configured."
|
|
691
|
+
].join(" ");
|
|
692
|
+
console.log("");
|
|
693
|
+
console.log("==========================================================");
|
|
694
|
+
console.log(" HETZNER PORT 25 UNBLOCK REQUEST");
|
|
695
|
+
console.log("==========================================================");
|
|
696
|
+
console.log("");
|
|
697
|
+
console.log(` Server: ${serverName} (ID: ${serverId || "unknown"})`);
|
|
698
|
+
console.log(` Server IP: ${serverIp || "(not detected)"}`);
|
|
699
|
+
console.log(` rDNS: ${rdns}`);
|
|
700
|
+
console.log("");
|
|
701
|
+
if (serverId && serverIp) {
|
|
702
|
+
log.info("Setting reverse DNS...");
|
|
703
|
+
try {
|
|
704
|
+
const rdnsData = await (await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/change_dns_ptr`, {
|
|
705
|
+
method: "POST",
|
|
706
|
+
headers: {
|
|
707
|
+
Authorization: `Bearer ${hetznerToken}`,
|
|
708
|
+
"Content-Type": "application/json"
|
|
709
|
+
},
|
|
710
|
+
body: JSON.stringify({ ip: serverIp, dns_ptr: rdns })
|
|
711
|
+
})).json();
|
|
712
|
+
if (rdnsData.action?.status === "running" || rdnsData.action?.status === "success")
|
|
713
|
+
log.success(`Reverse DNS set: ${serverIp} -> ${rdns}`);
|
|
714
|
+
else
|
|
715
|
+
log.warn(`rDNS response: ${JSON.stringify(rdnsData.error || rdnsData)}`);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
log.warn(`rDNS failed: ${getErrorMessage(error)}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
console.log("");
|
|
721
|
+
console.log(" To request port 25 unblock from Hetzner:");
|
|
722
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
723
|
+
console.log(" 1. Log in to https://console.hetzner.cloud");
|
|
724
|
+
console.log(` 2. Select project containing server "${serverName}"`);
|
|
725
|
+
console.log(" 3. Go to Support (left sidebar) -> New support request");
|
|
726
|
+
console.log(" 4. Use this template:");
|
|
727
|
+
console.log("");
|
|
728
|
+
console.log(" Subject: Request to unblock outbound port 25 (SMTP)");
|
|
729
|
+
console.log("");
|
|
730
|
+
console.log(" Body:");
|
|
731
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500");
|
|
732
|
+
console.log(` Server: ${serverName} (ID: ${serverId}, IP: ${serverIp})`);
|
|
733
|
+
console.log(` Use case: ${useCase}`);
|
|
734
|
+
console.log(` Reverse DNS: ${serverIp} -> ${rdns}`);
|
|
735
|
+
console.log(" We comply with all anti-spam regulations (CAN-SPAM, GDPR).");
|
|
736
|
+
console.log(" Please unblock outbound ports 25 and 465 for this server.");
|
|
737
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500");
|
|
738
|
+
console.log("");
|
|
739
|
+
console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
740
|
+
const consoleUrl = "https://console.hetzner.cloud";
|
|
741
|
+
try {
|
|
742
|
+
execSync(`open "${consoleUrl}" 2>/dev/null || xdg-open "${consoleUrl}" 2>/dev/null || echo ""`, { encoding: "utf-8" });
|
|
743
|
+
log.info(`Opened: ${consoleUrl}`);
|
|
744
|
+
} catch {
|
|
745
|
+
console.log(` Open: ${consoleUrl}`);
|
|
746
|
+
}
|
|
747
|
+
console.log("");
|
|
748
|
+
console.log(" After Hetzner approves, verify with:");
|
|
749
|
+
console.log(" buddy mail:port25:status --provider hetzner");
|
|
750
|
+
console.log("");
|
|
751
|
+
process.exit(ExitCode.Success);
|
|
752
|
+
}
|