• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 18938434872

30 Oct 2025 11:02AM UTC coverage: 74.808% (-0.07%) from 74.874%
18938434872

Pull #2056

github

web-flow
Merge b06a981d7 into a1780f086
Pull Request #2056: feat: backend i18n

10375 of 11193 branches covered (92.69%)

206 of 343 new or added lines in 14 files covered. (60.06%)

18 existing lines in 4 files now uncovered.

51557 of 68919 relevant lines covered (74.81%)

4389.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.28
/apps/nestjs-backend/src/features/mail-sender/mail-sender.service.ts
1
/* eslint-disable sonarjs/no-duplicate-string */
2✔
2
import { Injectable, Logger } from '@nestjs/common';
3
import { MailerService } from '@nestjs-modules/mailer';
4
import { HttpErrorCode } from '@teable/core';
5
import type { IMailTransportConfig } from '@teable/openapi';
6
import { MailType, CollaboratorType, SettingKey, MailTransporterType } from '@teable/openapi';
7
import { isString } from 'lodash';
8
import { I18nService } from 'nestjs-i18n';
9
import { createTransport } from 'nodemailer';
10
import { CacheService } from '../../cache/cache.service';
11
import { IMailConfig, MailConfig } from '../../configs/mail.config';
12
import { CustomHttpException } from '../../custom.exception';
13
import { EventEmitterService } from '../../event-emitter/event-emitter.service';
14
import { Events } from '../../event-emitter/events';
15
import { SettingOpenApiService } from '../setting/open-api/setting-open-api.service';
16
import { buildEmailFrom, type ISendMailOptions } from './mail-helpers';
17

18
@Injectable()
19
export class MailSenderService {
2✔
20
  private logger = new Logger(MailSenderService.name);
252✔
21
  private readonly defaultTransportConfig: IMailTransportConfig;
252✔
22

23
  constructor(
252✔
24
    private readonly mailService: MailerService,
252✔
25
    @MailConfig() private readonly mailConfig: IMailConfig,
252✔
26
    private readonly settingOpenApiService: SettingOpenApiService,
252✔
27
    private readonly eventEmitterService: EventEmitterService,
252✔
28
    private readonly cacheService: CacheService,
252✔
29
    private readonly i18n: I18nService
252✔
30
  ) {
252✔
31
    const { host, port, secure, auth, sender, senderName } = this.mailConfig;
252✔
32
    this.defaultTransportConfig = {
252✔
33
      senderName,
252✔
34
      sender,
252✔
35
      host,
252✔
36
      port,
252✔
37
      secure,
252✔
38
      auth: {
252✔
39
        user: auth.user || '',
252✔
40
        pass: auth.pass || '',
252✔
41
      },
252✔
42
    };
252✔
43
  }
252✔
44

45
  async checkSendMailRateLimit<T>(
252✔
46
    options: { email: string; rateLimitKey: string; rateLimit: number },
10✔
47
    fn: () => Promise<T>
10✔
48
  ) {
10✔
49
    const { email, rateLimitKey: _rateLimitKey, rateLimit: _rateLimit } = options;
10✔
50
    // If rate limit is 0, skip rate limiting entirely
10✔
51
    if (_rateLimit <= 0) {
10✔
52
      return await fn();
10✔
53
    }
10✔
54
    const rateLimit = _rateLimit - 2; // 2 seconds for network latency
×
55
    const rateLimitKey = `send-mail-rate-limit:${_rateLimitKey}:${email}` as const;
×
56
    const existingRateLimit = await this.cacheService.get(rateLimitKey);
×
57
    if (existingRateLimit) {
×
58
      throw new CustomHttpException(
×
59
        `Reached the rate limit of sending mail, please try again after ${rateLimit} seconds`,
×
60
        HttpErrorCode.TOO_MANY_REQUESTS,
×
61
        {
×
62
          seconds: _rateLimit,
×
63
        }
×
64
      );
65
    }
×
66
    const result = await fn();
×
67
    await this.cacheService.setDetail(rateLimitKey, true, rateLimit);
×
68
    return result;
×
69
  }
×
70

71
  // https://nodemailer.com/smtp#connection-options
252✔
72
  async createTransporter(config: IMailTransportConfig) {
252✔
73
    const { connectionTimeout, greetingTimeout, dnsTimeout } = this.mailConfig;
103✔
74
    const transporter = createTransport({
103✔
75
      ...config,
103✔
76
      connectionTimeout,
103✔
77
      greetingTimeout,
103✔
78
      dnsTimeout,
103✔
79
    });
103✔
80
    const templateAdapter = this.mailService['templateAdapter'];
103✔
81
    this.mailService['initTemplateAdapter'](templateAdapter, transporter);
103✔
82
    return transporter;
103✔
83
  }
103✔
84

85
  async sendMailByConfig(mailOptions: ISendMailOptions, config: IMailTransportConfig) {
252✔
86
    const instance = await this.createTransporter(config);
103✔
87
    const from =
103✔
88
      mailOptions.from ??
103✔
89
      buildEmailFrom(config.sender, mailOptions.senderName ?? config.senderName);
103✔
90
    return instance.sendMail({ ...mailOptions, from });
103✔
91
  }
103✔
92

93
  async getTransportConfigByName(name?: MailTransporterType) {
252✔
94
    const setting = await this.settingOpenApiService.getSetting([
103✔
95
      SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG,
103✔
96
      SettingKey.AUTOMATION_MAIL_TRANSPORT_CONFIG,
103✔
97
    ]);
103✔
98
    const defaultConfig = this.defaultTransportConfig;
103✔
99
    const notifyConfig = setting[SettingKey.NOTIFY_MAIL_TRANSPORT_CONFIG];
103✔
100
    const automationConfig = setting[SettingKey.AUTOMATION_MAIL_TRANSPORT_CONFIG];
103✔
101

102
    const notifyTransport = notifyConfig || defaultConfig;
103✔
103
    const automationTransport = automationConfig || notifyTransport || defaultConfig;
103✔
104

105
    let config = defaultConfig;
103✔
106
    if (name === MailTransporterType.Automation) {
103✔
107
      config = automationTransport;
×
108
    } else if (name === MailTransporterType.Notify) {
103✔
109
      config = notifyTransport;
103✔
110
    }
103✔
111

112
    return config;
103✔
113
  }
103✔
114

115
  async notifyMergeOptions(list: ISendMailOptions & { mailType: MailType }[], brandName: string) {
252✔
116
    return {
×
NEW
117
      subject: this.i18n.t('common.email.templates.notify.subject', {
×
NEW
118
        args: { brandName },
×
NEW
119
      }),
×
120
      template: 'normal',
×
121
      context: {
×
122
        partialBody: 'notify-merge-body',
×
123
        brandName,
×
124
        list: list.map((item) => ({
×
125
          ...item,
×
126
          mailType: item.mailType,
×
127
        })),
×
128
      },
×
129
    };
×
130
  }
×
131

132
  async sendMailByTransporterName(
252✔
133
    mailOptions: ISendMailOptions,
103✔
134
    transporterName?: MailTransporterType,
103✔
135
    type?: MailType
103✔
136
  ) {
103✔
137
    const mergeNotifyType = [MailType.System, MailType.Notify, MailType.Common];
103✔
138
    const checkNotify =
103✔
139
      type && transporterName === MailTransporterType.Notify && mergeNotifyType.includes(type);
103✔
140
    const checkTo = mailOptions.to && isString(mailOptions.to);
103✔
141
    if (checkNotify && checkTo) {
103✔
142
      this.eventEmitterService.emit(Events.NOTIFY_MAIL_MERGE, {
×
143
        payload: { ...mailOptions, mailType: type },
×
144
      });
×
145
      return true;
×
146
    }
×
147
    const config = await this.getTransportConfigByName(transporterName);
103✔
148
    return await this.sendMailByConfig(mailOptions, config);
103✔
149
  }
103✔
150

151
  async sendMail(
252✔
152
    mailOptions: ISendMailOptions,
103✔
153
    extra?: {
103✔
154
      shouldThrow?: boolean;
155
      type?: MailType;
156
      transportConfig?: IMailTransportConfig;
157
      transporterName?: MailTransporterType;
158
    }
103✔
159
  ): Promise<boolean> {
103✔
160
    const { type, transportConfig, transporterName } = extra || {};
103✔
161
    let sender: Promise<boolean>;
103✔
162
    if (transportConfig) {
103✔
163
      sender = this.sendMailByConfig(mailOptions, transportConfig).then(() => true);
×
164
    } else if (transporterName) {
103✔
165
      sender = this.sendMailByTransporterName(mailOptions, transporterName, type).then(() => true);
103✔
166
    } else {
103✔
167
      const from =
×
168
        mailOptions.from ??
×
169
        buildEmailFrom(
×
170
          this.mailConfig.sender,
×
171
          mailOptions.senderName ?? this.mailConfig.senderName
×
172
        );
173

174
      sender = this.mailService.sendMail({ ...mailOptions, from }).then(() => true);
×
175
    }
×
176

177
    if (extra?.shouldThrow) {
103✔
178
      return sender;
×
179
    }
×
180

181
    return sender.catch((reason) => {
103✔
182
      if (reason) {
103✔
183
        console.error(reason);
103✔
184
        this.logger.error(`Mail sending failed: ${reason.message}`, reason.stack);
103✔
185
      }
103✔
186
      return false;
103✔
187
    });
103✔
188
  }
103✔
189

190
  inviteEmailOptions(info: {
252✔
191
    name: string;
192
    brandName: string;
193
    email: string;
194
    resourceName: string;
195
    resourceType: CollaboratorType;
196
    inviteUrl: string;
197
  }) {
86✔
198
    const { name, email, inviteUrl, resourceName, resourceType, brandName } = info;
86✔
199
    const resourceAlias = resourceType === CollaboratorType.Space ? 'Space' : 'Base';
86✔
200

201
    return {
86✔
202
      subject: this.i18n.t('common.email.templates.invite.subject', {
86✔
203
        args: { name, email, resourceAlias, resourceName, brandName },
86✔
204
      }),
86✔
205
      template: 'normal',
86✔
206
      context: {
86✔
207
        name,
86✔
208
        email,
86✔
209
        resourceName,
86✔
210
        resourceAlias,
86✔
211
        inviteUrl,
86✔
212
        partialBody: 'invite',
86✔
213
        brandName,
86✔
214
        title: this.i18n.t('common.email.templates.invite.title'),
86✔
215
        message: this.i18n.t('common.email.templates.invite.message', {
86✔
216
          args: { name, email, resourceAlias, resourceName },
86✔
217
        }),
86✔
218
        buttonText: this.i18n.t('common.email.templates.invite.buttonText'),
86✔
219
      },
86✔
220
    };
86✔
221
  }
86✔
222

223
  async collaboratorCellTagEmailOptions(info: {
252✔
224
    notifyId: string;
225
    fromUserName: string;
226
    refRecord: {
227
      baseId: string;
228
      tableId: string;
229
      tableName: string;
230
      fieldName: string;
231
      recordIds: string[];
232
    };
233
  }) {
7✔
234
    const {
7✔
235
      notifyId,
7✔
236
      fromUserName,
7✔
237
      refRecord: { baseId, tableId, fieldName, tableName, recordIds },
7✔
238
    } = info;
7✔
239
    let subject, partialBody;
7✔
240
    const refLength = recordIds.length;
7✔
241

242
    const viewRecordUrlPrefix = `${this.mailConfig.origin}/base/${baseId}/${tableId}`;
7✔
243
    const { brandName } = await this.settingOpenApiService.getServerBrand();
7✔
244
    if (refLength <= 1) {
7✔
245
      subject = this.i18n.t('common.email.templates.collaboratorCellTag.subject', {
7✔
246
        args: { fromUserName, fieldName, tableName },
7✔
247
      });
7✔
248
      partialBody = 'collaborator-cell-tag';
7✔
249
    } else {
7✔
NEW
250
      subject = this.i18n.t('common.email.templates.collaboratorMultiRowTag.subject', {
×
NEW
251
        args: { fromUserName, refLength, tableName },
×
NEW
252
      });
×
253
      partialBody = 'collaborator-multi-row-tag';
×
254
    }
×
255

256
    return {
7✔
257
      notifyMessage: subject,
7✔
258
      subject: `${subject} - ${brandName}`,
7✔
259
      template: 'normal',
7✔
260
      context: {
7✔
261
        notifyId,
7✔
262
        fromUserName,
7✔
263
        refLength,
7✔
264
        tableName,
7✔
265
        fieldName,
7✔
266
        recordIds,
7✔
267
        viewRecordUrlPrefix,
7✔
268
        partialBody,
7✔
269
        brandName,
7✔
270
        title: this.i18n.t('common.email.templates.collaboratorCellTag.title', {
7✔
271
          args: { fromUserName, fieldName, tableName },
7✔
272
        }),
7✔
273
        buttonText: this.i18n.t('common.email.templates.collaboratorCellTag.buttonText'),
7✔
274
      },
7✔
275
    };
7✔
276
  }
7✔
277

278
  async htmlEmailOptions(info: {
252✔
279
    to: string;
280
    title: string;
281
    message: string;
282
    buttonUrl: string;
283
    buttonText: string;
284
  }) {
2✔
285
    const { title, message } = info;
2✔
286
    const { brandName } = await this.settingOpenApiService.getServerBrand();
2✔
287
    return {
2✔
288
      notifyMessage: message,
2✔
289
      subject: `${title} - ${brandName}`,
2✔
290
      template: 'normal',
2✔
291
      context: {
2✔
292
        partialBody: 'html-body',
2✔
293
        brandName,
2✔
294
        ...info,
2✔
295
      },
2✔
296
    };
2✔
297
  }
2✔
298

299
  async commonEmailOptions(info: {
252✔
300
    to: string;
301
    title: string;
302
    message: string;
303
    buttonUrl: string;
304
    buttonText: string;
UNCOV
305
  }) {
×
UNCOV
306
    const { title, message } = info;
×
UNCOV
307
    const { brandName } = await this.settingOpenApiService.getServerBrand();
×
UNCOV
308
    return {
×
UNCOV
309
      notifyMessage: message,
×
UNCOV
310
      subject: `${title} - ${brandName}`,
×
UNCOV
311
      template: 'normal',
×
UNCOV
312
      context: {
×
UNCOV
313
        partialBody: 'common-body',
×
UNCOV
314
        brandName,
×
UNCOV
315
        ...info,
×
UNCOV
316
      },
×
UNCOV
317
    };
×
UNCOV
318
  }
×
319

320
  async sendTestEmailOptions(info: { message?: string }) {
252✔
NEW
321
    const { message } = info;
×
322
    const { brandName } = await this.settingOpenApiService.getServerBrand();
×
323
    return {
×
NEW
324
      subject: this.i18n.t('common.email.templates.test.subject', {
×
NEW
325
        args: { brandName },
×
NEW
326
      }),
×
327
      template: 'normal',
×
328
      context: {
×
NEW
329
        partialBody: 'html-body',
×
330
        brandName,
×
NEW
331
        title: this.i18n.t('common.email.templates.test.title'),
×
NEW
332
        message: message || this.i18n.t('common.email.templates.test.message'),
×
NEW
333
      },
×
NEW
334
    };
×
NEW
335
  }
×
336

337
  async waitlistInviteEmailOptions(info: {
252✔
338
    code: string;
339
    times: number;
340
    name: string;
341
    email: string;
342
    waitlistInviteUrl: string;
343
  }) {
2✔
344
    const { code, times, name, email, waitlistInviteUrl } = info;
2✔
345
    const { brandName } = await this.settingOpenApiService.getServerBrand();
2✔
346
    return {
2✔
347
      subject: this.i18n.t('common.email.templates.waitlistInvite.subject', {
2✔
348
        args: { name, email, brandName },
2✔
349
      }),
2✔
350
      template: 'normal',
2✔
351
      context: {
2✔
352
        ...info,
2✔
353
        partialBody: 'common-body',
2✔
354
        brandName,
2✔
355
        title: this.i18n.t('common.email.templates.waitlistInvite.title'),
2✔
356
        message: this.i18n.t('common.email.templates.waitlistInvite.message', {
2✔
357
          args: { brandName, code, times },
2✔
358
        }),
2✔
359
        buttonText: this.i18n.t('common.email.templates.waitlistInvite.buttonText'),
2✔
360
        buttonUrl: waitlistInviteUrl,
2✔
361
      },
2✔
362
    };
2✔
363
  }
2✔
364

365
  async resetPasswordEmailOptions(info: { name: string; email: string; resetPasswordUrl: string }) {
252✔
NEW
366
    const { resetPasswordUrl } = info;
×
NEW
367
    const { brandName } = await this.settingOpenApiService.getServerBrand();
×
368

NEW
369
    return {
×
NEW
370
      subject: this.i18n.t('common.email.templates.resetPassword.subject', {
×
NEW
371
        args: {
×
NEW
372
          brandName,
×
NEW
373
        },
×
NEW
374
      }),
×
NEW
375
      template: 'normal',
×
NEW
376
      context: {
×
377
        partialBody: 'reset-password',
×
NEW
378
        brandName,
×
NEW
379
        title: this.i18n.t('common.email.templates.resetPassword.title'),
×
NEW
380
        message: this.i18n.t('common.email.templates.resetPassword.message'),
×
NEW
381
        buttonText: this.i18n.t('common.email.templates.resetPassword.buttonText'),
×
NEW
382
        buttonUrl: resetPasswordUrl,
×
383
      },
×
384
    };
×
385
  }
×
386

387
  async sendSignupVerificationEmailOptions(info: { code: string; expiresIn: string }) {
252✔
388
    const { code, expiresIn } = info;
4✔
389
    const { brandName } = await this.settingOpenApiService.getServerBrand();
4✔
390
    return {
4✔
391
      subject: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.subject', {
4✔
392
        args: {
4✔
393
          brandName,
4✔
394
        },
4✔
395
      }),
4✔
396
      template: 'normal',
4✔
397
      context: {
4✔
398
        partialBody: 'email-verify-code',
4✔
399
        brandName,
4✔
400
        title: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.title'),
4✔
401
        message: this.i18n.t('common.email.templates.emailVerifyCode.signupVerification.message', {
4✔
402
          args: {
4✔
403
            code,
4✔
404
            expiresIn,
4✔
405
          },
4✔
406
        }),
4✔
407
      },
4✔
408
    };
4✔
409
  }
4✔
410

411
  async sendDomainVerificationEmailOptions(info: {
252✔
412
    domain: string;
413
    name: string;
414
    code: string;
415
    expiresIn: string;
NEW
416
  }) {
×
NEW
417
    const { domain, name, code, expiresIn } = info;
×
NEW
418
    const { brandName } = await this.settingOpenApiService.getServerBrand();
×
NEW
419
    return {
×
NEW
420
      subject: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.subject', {
×
NEW
421
        args: {
×
NEW
422
          brandName,
×
NEW
423
        },
×
NEW
424
      }),
×
NEW
425
      template: 'normal',
×
NEW
426
      context: {
×
NEW
427
        partialBody: 'email-verify-code',
×
NEW
428
        brandName,
×
NEW
429
        title: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.title', {
×
NEW
430
          args: { domain, name },
×
NEW
431
        }),
×
NEW
432
        message: this.i18n.t('common.email.templates.emailVerifyCode.domainVerification.message', {
×
NEW
433
          args: {
×
NEW
434
            code,
×
NEW
435
            expiresIn,
×
NEW
436
          },
×
NEW
437
        }),
×
NEW
438
      },
×
NEW
439
    };
×
NEW
440
  }
×
441

442
  async sendChangeEmailCodeEmailOptions(info: { code: string; expiresIn: string }) {
252✔
443
    const { code, expiresIn } = info;
2✔
444
    const { brandName } = await this.settingOpenApiService.getServerBrand();
2✔
445
    return {
2✔
446
      subject: this.i18n.t(
2✔
447
        'common.email.templates.emailVerifyCode.changeEmailVerification.subject',
2✔
448
        {
2✔
449
          args: { brandName },
2✔
450
        }
2✔
451
      ),
452
      template: 'normal',
2✔
453
      context: {
2✔
454
        partialBody: 'email-verify-code',
2✔
455
        brandName,
2✔
456
        title: this.i18n.t('common.email.templates.emailVerifyCode.changeEmailVerification.title'),
2✔
457
        message: this.i18n.t(
2✔
458
          'common.email.templates.emailVerifyCode.changeEmailVerification.message',
2✔
459
          {
2✔
460
            args: { code, expiresIn },
2✔
461
          }
2✔
462
        ),
463
      },
2✔
464
    };
2✔
465
  }
2✔
466
}
252✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc