diff options
-rw-r--r-- | services/docker/mail-server/aws-sendmail/aws/retriver.ts | 31 | ||||
-rw-r--r-- | services/docker/mail-server/aws-sendmail/mail.ts | 104 |
2 files changed, 101 insertions, 34 deletions
diff --git a/services/docker/mail-server/aws-sendmail/aws/retriver.ts b/services/docker/mail-server/aws-sendmail/aws/retriver.ts index 1544c04..b599c15 100644 --- a/services/docker/mail-server/aws-sendmail/aws/retriver.ts +++ b/services/docker/mail-server/aws-sendmail/aws/retriver.ts @@ -6,10 +6,10 @@ import { S3Client, } from "@aws-sdk/client-s3"; -import { generateTimeStringForFileName } from "../util.ts"; -import { getLogger } from "../logger.ts"; import { AwsContext, s3MoveObject } from "./context.ts"; +import { getLogger } from "../logger.ts"; import { getConfig } from "../config.ts"; +import { Mail } from "../mail.ts"; export class AwsMailRetriever { readonly liveMailPrefix = "mail/live/"; @@ -21,7 +21,7 @@ export class AwsMailRetriever { constructor( aws: AwsContext, - private readonly callback: (rawMail: string) => Promise<void>, + private readonly callback: (mail: Mail) => Promise<void>, ) { const { region, credentials } = aws; this.s3Client = new S3Client({ region, credentials }); @@ -34,12 +34,6 @@ export class AwsMailRetriever { }); } - generateArchivePrefix(instant: Date | Temporal.Instant): string { - return `${this.archiveMailPrefix}${ - generateTimeStringForFileName(instant, true) - }/`; - } - async listLiveMails(): Promise<string[]> { const listCommand = new ListObjectsV2Command({ Bucket: this.mailBucket, @@ -55,7 +49,8 @@ export class AwsMailRetriever { const result: string[] = []; for (const object of res.Contents) { if (object.Key != null) { - result.push(object.Key); + // TODO: check prefix consistence here. + result.push(object.Key.slice(this.liveMailPrefix.length)); } else { getLogger().warn( "Listing live mails in S3 returns an object with no Key.", @@ -79,13 +74,25 @@ export class AwsMailRetriever { } const rawMail = await res.Body.transformToString(); - await this.callback(rawMail); + const mail = new Mail(rawMail); + mail.awsMessageId = messageId; + await this.callback(mail); // TODO: Continue here. - await s3MoveObject(this.s3Client, this.mailBucket, mailPath, ); + await s3MoveObject( + this.s3Client, + this.mailBucket, + mailPath, + `${this.archiveMailPrefix}${ + mail.simpleGetDateString("invalid-date") + }/${messageId}`, + ); } async recycleLiveMails() { const mails = await this.listLiveMails(); + for (const messageId of mails) { + await this.deliverS3MailObject(messageId); + } } } diff --git a/services/docker/mail-server/aws-sendmail/mail.ts b/services/docker/mail-server/aws-sendmail/mail.ts index d4bcf75..98afcaa 100644 --- a/services/docker/mail-server/aws-sendmail/mail.ts +++ b/services/docker/mail-server/aws-sendmail/mail.ts @@ -1,5 +1,6 @@ import { encodeBase64 } from "@std/encoding/base64"; import { getLogger } from "./logger.ts"; +import { generateTimeStringForFileName } from "./util.ts"; export type MailDeliverStateNotSent = { kind: "not-sent" }; export type MailDeliverStateDelivered = { @@ -21,8 +22,10 @@ class MailParseError extends Error { constructor( message: string, public readonly mail: Mail, + public readonly lineNumber?: number, options?: ErrorOptions, ) { + if (lineNumber != null) message += `(at line ${lineNumber})`; super(message, options); } } @@ -44,9 +47,14 @@ const eolNames = new Map([ ]); interface ParsedMail { - headerStr: string; - bodyStr: string; - sepStr: string; + sections: { + header: string; + body: string; + }; + /** + * The empty line between headers and body. + */ + sep: string; eol: string; } @@ -89,48 +97,100 @@ export class Mail { // "\r\n" is a false positive. if (twoEolMatch == null || twoEolMatch[0] === "\r\n") { throw new MailParseError( - "No header/body separator (2 successive EOLs) found. Cannot append headers.", + "No header/body section separator (2 successive EOLs) found.", this, ); } - const [eol, sepStr] = [twoEolMatch[1], twoEolMatch[2]]; + const [eol, sep] = [twoEolMatch[1], twoEolMatch[2]]; - if (eol !== sepStr) { + if (eol !== sep) { getLogger().warn( - `Different EOLs (${eolNames.get(eol)} \ - and ${eolNames.get(sepStr)}) found in mail.`, + `Different EOLs (${eolNames.get(eol)} and ${eolNames.get(sep)}) found.`, ); } return { - headerStr: this.raw.slice(0, twoEolMatch.index! + eol.length), - bodyStr: this.raw.slice(twoEolMatch.index! + eol.length + sepStr.length), - sepStr, + sections: { + header: this.raw.slice(0, twoEolMatch.index! + eol.length), + body: this.raw.slice(twoEolMatch.index! + eol.length + sep.length), + }, + sep, eol, }; } simpleParseHeaders(): [key: string, value: string][] { - const { headerStr } = this.simpleParse(); - const lines: string[] = []; - for (const line of headerStr.split(/\r?\n|\r/)) { + const { sections } = this.simpleParse(); + const headers: [string, string][] = []; + + let field: string | null = null; + let lineNumber = 1; + + const handleField = () => { + if (field == null) return; + const sepPos = field.indexOf(":"); + if (sepPos === -1) { + throw new MailParseError( + "No ':' in the header field.", + this, + lineNumber, + ); + } + headers.push([field.slice(0, sepPos).trim(), field.slice(sepPos + 1)]); + field = null; + }; + + for (const line of sections.header.trimEnd().split(/\r?\n|\r/)) { if (line.match(/^\s/)) { - if (lines.length === 0) { - throw new MailParseError("Header part starts with a space.", this); + if (field == null) { + throw new MailParseError( + "Header field starts with a space.", + this, + lineNumber, + ); + } + field += line; + } else { + handleField(); + field = line; + } + lineNumber += 1; + } + + handleField(); + + return headers; + } + + simpleGetDate(): Date | null { + const headers = this.simpleParseHeaders(); + for (const [key, value] of headers) { + if (key.toLowerCase() === "date") { + const date = new Date(value); + if (isNaN(date.getTime())) { + getLogger().warn(`Invalid date string (${value}) found in header.`); + return null; } - lines[lines.length - 1] += line; + return date; } } + return null; + } - // TODO: Continue here. + simpleGetDateString<T>(fallback: T): string | T { + const date = this.simpleGetDate(); + if (date == null) return fallback; + return generateTimeStringForFileName(date, true); } + // TODO: Add folding. appendHeaders(headers: [key: string, value: string][]) { - const { headerStr, bodyStr, sepStr, eol } = this.simpleParse(); - const newHeaderStr = headerStr + - headers.map(([k, v]) => `${k}: ${v}${eol}`).join(""); - this.raw = newHeaderStr + sepStr + bodyStr; + const { sections, sep, eol } = this.simpleParse(); + + this.raw = sections.header + + headers.map(([k, v]) => `${k}: ${v}`).join(eol) + eol + sep + + sections.body; } } |