Skip to content

Commit 40074da

Browse files
tefkahkalilsn
andauthored
(self host 1/?): allow users to configure their own email service (#972)
* feat: allow different email providers to be used when self-hosting, or allow users to disable email * fix: handle errors and fix types * chore: capitalize Gmail properly Co-authored-by: Kalil Smith-Nuevelle <[email protected]> --------- Co-authored-by: Kalil Smith-Nuevelle <[email protected]>
1 parent 3a61368 commit 40074da

File tree

6 files changed

+225
-30
lines changed

6 files changed

+225
-30
lines changed

core/actions/email/run.ts

+24-10
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getPubsWithRelatedValuesAndChildren } from "~/lib/server";
1616
import { getCommunitySlug } from "~/lib/server/cache/getCommunitySlug";
1717
import * as Email from "~/lib/server/email";
1818
import { renderMarkdownWithPub } from "~/lib/server/render/pub/renderMarkdownWithPub";
19+
import { isClientException } from "~/lib/serverActions";
1920
import { defineRun } from "../types";
2021

2122
export const run = defineRun<typeof action>(async ({ pub, config, args, communityId }) => {
@@ -88,26 +89,39 @@ export const run = defineRun<typeof action>(async ({ pub, config, args, communit
8889
true
8990
);
9091

91-
await Email.generic({
92+
const result = await Email.generic({
9293
to: expect(recipient?.user.email ?? recipientEmail),
9394
subject,
9495
html,
9596
}).send();
97+
98+
if (isClientException(result)) {
99+
logger.error({
100+
msg: "An error occurred while sending an email",
101+
error: result.error,
102+
pub,
103+
config,
104+
args,
105+
renderMarkdownWithPubContext,
106+
});
107+
} else {
108+
logger.info({
109+
msg: "Successfully sent email",
110+
pub,
111+
config,
112+
args,
113+
renderMarkdownWithPubContext,
114+
});
115+
}
116+
117+
return result;
96118
} catch (error) {
97-
logger.error({ msg: "email", error });
119+
logger.error({ msg: "Failed to send email", error });
98120

99121
return {
100122
title: "Failed to Send Email",
101123
error: error.message,
102124
cause: error,
103125
};
104126
}
105-
106-
logger.info({ msg: "email", pub, config, args });
107-
108-
return {
109-
success: true,
110-
report: "Email sent",
111-
data: {},
112-
};
113127
});

core/actions/googleDriveImport/getGDriveFiles.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Auth } from "googleapis";
2+
13
import { google } from "googleapis";
24

35
import { logger } from "logger";
@@ -8,7 +10,21 @@ import { env } from "~/lib/env/env.mjs";
810
// const keyFilePath = path.join(process.cwd(), 'src/utils/google/keyFile.json');
911
// const keyFile = JSON.parse(fs.readFileSync(keyFilePath, 'utf8'));
1012
// const keyFile = JSON.parse(env.GCLOUD_KEY_FILE);
11-
const keyFile = JSON.parse(Buffer.from(env.GCLOUD_KEY_FILE, "base64").toString());
13+
14+
let keyFile: Auth.JWTInput;
15+
16+
try {
17+
if (!env.GCLOUD_KEY_FILE) {
18+
throw new Error(
19+
"GCLOUD_KEY_FILE is not set. You must set this to use the Google Drive import."
20+
);
21+
}
22+
23+
keyFile = JSON.parse(Buffer.from(env.GCLOUD_KEY_FILE, "base64").toString());
24+
} catch (e) {
25+
logger.error("Error parsing Google Cloud key file");
26+
throw e;
27+
}
1228

1329
// Configure a JWT auth client
1430
const auth = new google.auth.JWT(keyFile.client_email, undefined, keyFile.private_key, [

core/lib/env/env.mjs

+21-5
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,23 @@
33
import { createEnv } from "@t3-oss/env-nextjs";
44
import { z } from "zod";
55

6+
/**
7+
* Parameters which are optional if the app is self-hosted
8+
* but we do want checked for our AWS deploys
9+
*
10+
* @template {import("zod").ZodTypeAny} Z
11+
* @param {Z} schema
12+
*/
13+
const selfHostedOptional = (schema) => {
14+
return process.env.SELF_HOSTED ? schema.optional() : schema;
15+
};
16+
617
export const env = createEnv({
718
shared: {
819
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
920
},
1021
server: {
22+
SELF_HOSTED: z.string().optional(),
1123
API_KEY: z.string(),
1224
ASSETS_BUCKET_NAME: z.string(),
1325
ASSETS_REGION: z.string(),
@@ -21,16 +33,20 @@ export const env = createEnv({
2133
KYSELY_DEBUG: z.string().optional(),
2234
KYSELY_ARTIFICIAL_LATENCY: z.coerce.number().optional(),
2335
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
24-
MAILGUN_SMTP_PASSWORD: z.string(),
25-
MAILGUN_SMTP_USERNAME: z.string(),
26-
MAILGUN_SMTP_HOST: z.string(),
27-
MAILGUN_SMTP_PORT: z.string(),
36+
MAILGUN_SMTP_PASSWORD: selfHostedOptional(z.string()),
37+
MAILGUN_SMTP_USERNAME: selfHostedOptional(z.string()),
38+
MAILGUN_SMTP_HOST: selfHostedOptional(z.string()),
39+
MAILGUN_SMTP_PORT: selfHostedOptional(z.string()),
40+
MAILGUN_SMTP_FROM: z.string().optional(),
41+
MAILGUN_SMTP_FROM_NAME: z.string().optional(),
42+
MAILGUN_INSECURE_SENDMAIL: z.string().optional(),
43+
MAILGUN_SMTP_SECURITY: z.enum(["ssl", "tls", "none"]).optional(),
2844
OTEL_SERVICE_NAME: z.string().optional(),
2945
HONEYCOMB_API_KEY: z.string().optional(),
3046
PUBPUB_URL: z.string().url(),
3147
INBUCKET_URL: z.string().url().optional(),
3248
CI: z.string().or(z.boolean()).optional(),
33-
GCLOUD_KEY_FILE: z.string(),
49+
GCLOUD_KEY_FILE: selfHostedOptional(z.string()),
3450
DATACITE_API_URL: z.string().optional(),
3551
DATACITE_REPOSITORY_ID: z.string().optional(),
3652
DATACITE_PASSWORD: z.string().optional(),

core/lib/server/email.tsx

+10-5
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,18 @@ import type { XOR } from "../types";
1111
import type { FormInviteLinkProps } from "./form";
1212
import { db } from "~/kysely/database";
1313
import { createMagicLink } from "~/lib/authentication/createMagicLink";
14+
import { env } from "../env/env.mjs";
1415
import { createFormInviteLink } from "./form";
15-
import { smtpclient } from "./mailgun";
16+
import { getSmtpClient } from "./mailgun";
1617

1718
const FIFTEEN_MINUTES = 1000 * 60 * 15;
1819

1920
type RequiredOptions = Required<Pick<SendMailOptions, "to" | "subject">> &
2021
XOR<{ html: string }, { text: string }>;
2122

2223
export const DEFAULT_OPTIONS = {
23-
24-
name: `PubPub Team`,
24+
from: env.MAILGUN_SMTP_FROM ?? "[email protected]",
25+
name: env.MAILGUN_SMTP_FROM_NAME ?? "PubPub Team",
2526
} as const;
2627

2728
// export class Email {
@@ -33,7 +34,9 @@ function buildSend(emailPromise: () => Promise<RequiredOptions>) {
3334
options?: Partial<Omit<SendMailOptions, "to" | "subject" | "html">> & {
3435
name?: string;
3536
}
36-
) => Promise<{ success: true; report?: string } | { error: string }>,
37+
) => Promise<
38+
{ success: true; report?: string; data: Record<string, unknown> } | { error: string }
39+
>,
3740
};
3841
}
3942

@@ -54,7 +57,7 @@ async function send(
5457
},
5558
});
5659

57-
const send = await smtpclient.sendMail({
60+
await getSmtpClient().sendMail({
5861
from: `${options?.name ?? DEFAULT_OPTIONS.name} <${options?.from ?? DEFAULT_OPTIONS.from}>`,
5962
to: required.to,
6063
subject: required.subject,
@@ -65,6 +68,8 @@ async function send(
6568

6669
return {
6770
success: true,
71+
report: "Email sent",
72+
data: {},
6873
};
6974
} catch (error) {
7075
logger.error({

core/lib/server/mailgun.ts

+77-9
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,82 @@
1+
import type SMTPPool from "nodemailer/lib/smtp-pool";
2+
13
import nodemailer from "nodemailer";
24

35
import { env } from "~/lib/env/env.mjs";
46

5-
export const smtpclient = nodemailer.createTransport({
6-
pool: true,
7-
host: env.MAILGUN_SMTP_HOST,
8-
port: parseInt(env.MAILGUN_SMTP_PORT),
9-
secure: env.MAILGUN_SMTP_HOST !== "localhost" && !env.CI,
10-
auth: {
11-
user: env.MAILGUN_SMTP_USERNAME,
12-
pass: env.MAILGUN_SMTP_PASSWORD,
7+
let smtpclient: nodemailer.Transporter;
8+
9+
const TLS_CONFIG = {
10+
secure: false,
11+
opportunisticTLS: true,
12+
tls: {
13+
ciphers: "SSLv3",
14+
rejectUnauthorized: false,
1315
},
14-
});
16+
} as const satisfies Partial<SMTPPool.Options>;
17+
18+
const SSL_CONFIG = {
19+
secure: true,
20+
} as const satisfies Partial<SMTPPool.Options>;
21+
22+
const NO_CONFIG = {
23+
secure: false,
24+
opportunisticTLS: true,
25+
} as const satisfies Partial<SMTPPool.Options>;
26+
27+
const guessSecurityType = () => {
28+
if (env.MAILGUN_SMTP_PORT === "465") {
29+
return "ssl";
30+
}
31+
32+
if (env.MAILGUN_SMTP_PORT === "587") {
33+
return "tls";
34+
}
35+
36+
return "none";
37+
};
38+
39+
const getSecurityConfig = () => {
40+
const securityType = env.MAILGUN_SMTP_SECURITY ?? guessSecurityType();
41+
42+
if (securityType === "ssl") {
43+
return SSL_CONFIG;
44+
}
45+
46+
if (securityType === "tls") {
47+
return TLS_CONFIG;
48+
}
49+
50+
return NO_CONFIG;
51+
};
52+
53+
export const getSmtpClient = () => {
54+
const securityConfig = getSecurityConfig();
55+
56+
if (
57+
!env.MAILGUN_SMTP_HOST ||
58+
!env.MAILGUN_SMTP_PORT ||
59+
!env.MAILGUN_SMTP_USERNAME ||
60+
!env.MAILGUN_SMTP_PASSWORD
61+
) {
62+
throw new Error(
63+
"Missing required SMTP configuration. Please set MAILGUN_SMTP_HOST, MAILGUN_SMTP_PORT, MAILGUN_SMTP_USERNAME, and MAILGUN_SMTP_PASSWORD in order to send emails."
64+
);
65+
}
66+
67+
if (!smtpclient) {
68+
smtpclient = nodemailer.createTransport({
69+
...securityConfig,
70+
pool: true,
71+
host: env.MAILGUN_SMTP_HOST,
72+
port: parseInt(env.MAILGUN_SMTP_PORT),
73+
secure: securityConfig.secure && env.MAILGUN_SMTP_HOST !== "localhost" && !env.CI,
74+
auth: {
75+
user: env.MAILGUN_SMTP_USERNAME,
76+
pass: env.MAILGUN_SMTP_PASSWORD,
77+
},
78+
});
79+
}
80+
81+
return smtpclient;
82+
};

self-host/README.md

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Self-host documentation
2+
3+
For the most part, self-hosting PubPub is a matter of deploying the app and the database, which you can do with the accompanying docker-compose file in `self-host/docker-compose.yml`.
4+
5+
However, there are a few key things you need to know about.
6+
7+
## Email
8+
9+
To be able to send emails, you need to set some kind of email provider.
10+
11+
We recommend using [Mailgun](https://www.mailgun.com/).
12+
13+
Other common options are [SendGrid](https://sendgrid.com/) and [Postmark](https://postmarkapp.com/).
14+
15+
You can also use an existing GMail or Office365 account to relay emails through PubPub.
16+
17+
### Setup
18+
19+
#### Mailgun
20+
21+
To use Mailgun, you will need to create an account on [Mailgun](https://www.mailgun.com/) and set the following environment variables:
22+
23+
```sh
24+
MAILGUN_SMTP_HOST="smtp.mailgun.org"
25+
MAILGUN_SMTP_PORT=587
26+
MAILGUN_SMTP_USERNAME="[email protected]"
27+
MAILGUN_SMTP_PASSWORD="your-mailgun-password"
28+
MAILGUN_SMTP_FROM="[email protected]"
29+
MAILGUN_SMTP_FROM_NAME="Your Organization"
30+
```
31+
32+
#### Gmail
33+
34+
To use Gmail to relay emails through PubPub, you will need to create an [app password](https://support.google.com/accounts/answer/185833?hl=en).
35+
36+
You will be limited to 2000 emails per day by default this way.
37+
38+
```sh
39+
MAILGUN_SMTP_HOST="smtp.gmail.com"
40+
MAILGUN_SMTP_PORT=587 # or 465 for SSL
41+
MAILGUN_SMTP_USERNAME="[email protected]"
42+
MAILGUN_SMTP_PASSWORD="your app password" # this will be a 16 character string
43+
MAILGUN_SMTP_FROM="[email protected]" # technically optional, but you will almost definitely need to set this.
44+
MAILGUN_SMTP_FROM_NAME="Your Organization" # Optional, will default to "PubPub Team"
45+
```
46+
47+
If you need a higher limit of 10,000 emails, you can use the SMTP relay service. This will require extra configuration however:
48+
https://support.google.com/a/answer/176600?hl=en
49+
50+
```sh
51+
MAILGUN_SMTP_HOST="smtp-relay.gmail.com"
52+
MAILGUN_SMTP_PORT=587 # or 465 for SSL
53+
MAILGUN_SMTP_USERNAME="[email protected]"
54+
MAILGUN_SMTP_PASSWORD="your app password" # this will be a 16 character string
55+
MAILGUN_SMTP_FROM="[email protected]" # technically optional, but you will almost definitely need to set this.
56+
MAILGUN_SMTP_FROM_NAME="Your Organization" # Optional, will default to "PubPub Team"
57+
```
58+
59+
#### Office 365
60+
61+
You can (for now) send emails through Office 365 Outlook/Exchange through SMTP, although Microsoft has repeatedly stated they will likely deprecate this feature in the future.
62+
63+
You cannot send emails through shared mailboxes, you will need to an existing Microsoft account with a valid Office 365 subscription.
64+
65+
```sh
66+
MAILGUN_SMTP_HOST="smtp.office365.com"
67+
MAILGUN_SMTP_PORT=587
68+
MAILGUN_SMTP_USERNAME="[email protected]"
69+
MAILGUN_SMTP_PASSWORD="your-password"
70+
MAILGUN_SMTP_FROM="[email protected]" # technically optional, but you will almost definitely need to set this, as it will use `[email protected]` by default.
71+
MAILGUN_SMTP_FROM_NAME="Your Organization" # Optional, will default to "PubPub Team"
72+
```
73+
74+
#### No email
75+
76+
You can technically leave the email provider blank, but this will disable the email functionality. The email action will still be visible in the UI, but it will fail when you try to send an email.

0 commit comments

Comments
 (0)